commit d21f90672bd26192a4e74946b61c48a8e38abadf Author: Jane <272005125@qq.com> Date: Mon Feb 19 17:25:32 2024 +0800 first commit diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..1d93173 --- /dev/null +++ b/.babelrc @@ -0,0 +1,14 @@ +{ + "presets": [ + ["env", { "modules": false }], + "stage-2" + ], + "plugins": ["transform-runtime"], + "comments": false, + "env": { + "test": { + "presets": ["env", "stage-2"], + "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df87c63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +dist +.history +images +docs +Dockerfile +README.md +build.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d08a1a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9907604 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +build/*.js +config/*.js +src/libs/*.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..3c6c4ba --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,44 @@ +// http://eslint.org/docs/user-guide/configuring + +module.exports = { + root: true, + parser: 'babel-eslint', + parserOptions: { + sourceType: 'module' + }, + env: { + browser: true, + }, + extends: 'airbnb-base', + // required to lint *.vue files + plugins: [ + 'html' + ], + globals: { + "NODE_ENV": false, + "VERSION": false + }, + // check if imports actually resolve + 'settings': { + 'import/resolver': { + 'webpack': { + 'config': 'build/webpack.base.conf.js' + } + } + }, + // add your custom rules here + 'rules': { + 'no-param-reassign': [2, { 'props': false }], + // don't require .vue extension when importing + 'import/extensions': ['error', 'always', { + 'js': 'never', + 'vue': 'never' + }], + // allow optionalDependencies + 'import/no-extraneous-dependencies': ['error', { + 'optionalDependencies': ['test/unit/index.js'] + }], + // allow debugger during development + 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc4299c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules/ +dist/ +.history +.idea +npm-debug.log* +.vscode +stackedit_v4 +chrome-app/*.zip +/test/unit/coverage/ diff --git a/.postcssrc.js b/.postcssrc.js new file mode 100644 index 0000000..ea9a5ab --- /dev/null +++ b/.postcssrc.js @@ -0,0 +1,8 @@ +// https://github.com/michael-ciniawsky/postcss-load-config + +module.exports = { + "plugins": { + // to edit target browsers: use "browserlist" field in package.json + "autoprefixer": {} + } +} diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 0000000..e9549f8 --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,7 @@ +{ + "processors": ["stylelint-processor-html"], + "extends": "stylelint-config-standard", + "rules": { + "no-empty-source": null + } +} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1d541e6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: node_js + +node_js: + - "12" + +services: + - docker + +before_deploy: + # Run docker build + - docker build -t benweet/stackedit . + # Install Helm + - curl -SL -o /tmp/get_helm.sh https://git.io/get_helm.sh + - chmod 700 /tmp/get_helm.sh + - /tmp/get_helm.sh + - helm init --client-only + +deploy: + provider: script + script: bash build/deploy.sh + on: + tags: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/build/build.js b/build/build.js new file mode 100644 index 0000000..6b8add1 --- /dev/null +++ b/build/build.js @@ -0,0 +1,35 @@ +require('./check-versions')() + +process.env.NODE_ENV = 'production' + +var ora = require('ora') +var rm = require('rimraf') +var path = require('path') +var chalk = require('chalk') +var webpack = require('webpack') +var config = require('../config') +var webpackConfig = require('./webpack.prod.conf') + +var spinner = ora('building for production...') +spinner.start() + +rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { + if (err) throw err + webpack(webpackConfig, function (err, stats) { + spinner.stop() + if (err) throw err + process.stdout.write(stats.toString({ + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false + }) + '\n\n') + + console.log(chalk.cyan(' Build complete.\n')) + console.log(chalk.yellow( + ' Tip: built files are meant to be served over an HTTP server.\n' + + ' Opening index.html over file:// won\'t work.\n' + )) + }) +}) diff --git a/build/check-versions.js b/build/check-versions.js new file mode 100644 index 0000000..100f3a0 --- /dev/null +++ b/build/check-versions.js @@ -0,0 +1,48 @@ +var chalk = require('chalk') +var semver = require('semver') +var packageConfig = require('../package.json') +var shell = require('shelljs') +function exec (cmd) { + return require('child_process').execSync(cmd).toString().trim() +} + +var versionRequirements = [ + { + name: 'node', + currentVersion: semver.clean(process.version), + versionRequirement: packageConfig.engines.node + }, +] + +if (shell.which('npm')) { + versionRequirements.push({ + name: 'npm', + currentVersion: exec('npm --version'), + versionRequirement: packageConfig.engines.npm + }) +} + +module.exports = function () { + var warnings = [] + for (var i = 0; i < versionRequirements.length; i++) { + var mod = versionRequirements[i] + if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { + warnings.push(mod.name + ': ' + + chalk.red(mod.currentVersion) + ' should be ' + + chalk.green(mod.versionRequirement) + ) + } + } + + if (warnings.length) { + console.log('') + console.log(chalk.yellow('To use this template, you must update following to modules:')) + console.log() + for (var i = 0; i < warnings.length; i++) { + var warning = warnings[i] + console.log(' ' + warning) + } + console.log() + process.exit(1) + } +} diff --git a/build/deploy.sh b/build/deploy.sh new file mode 100644 index 0000000..931f81a --- /dev/null +++ b/build/deploy.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +# Tag and push docker image +docker login -u benweet -p "$DOCKER_PASSWORD" +docker tag benweet/stackedit "benweet/stackedit:$TRAVIS_TAG" +docker push benweet/stackedit:$TRAVIS_TAG +docker tag benweet/stackedit:$TRAVIS_TAG benweet/stackedit:latest +docker push benweet/stackedit:latest + +# Build the chart +cd "$TRAVIS_BUILD_DIR" +npm run chart + +# Add chart to helm repository +git clone --branch master "https://benweet:$GITHUB_TOKEN@github.com/benweet/stackedit-charts.git" /tmp/charts +cd /tmp/charts +helm package "$TRAVIS_BUILD_DIR/dist/stackedit" +helm repo index --url https://benweet.github.io/stackedit-charts/ . +git config user.name "Benoit Schweblin" +git config user.email "benoit.schweblin@gmail.com" +git add . +git commit -m "Added $TRAVIS_TAG" +git push origin master diff --git a/build/dev-client.js b/build/dev-client.js new file mode 100644 index 0000000..18aa1e2 --- /dev/null +++ b/build/dev-client.js @@ -0,0 +1,9 @@ +/* eslint-disable */ +require('eventsource-polyfill') +var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') + +hotClient.subscribe(function (event) { + if (event.action === 'reload') { + window.location.reload() + } +}) diff --git a/build/dev-server.js b/build/dev-server.js new file mode 100644 index 0000000..a517b6c --- /dev/null +++ b/build/dev-server.js @@ -0,0 +1,94 @@ +require('./check-versions')() + +var config = require('../config') +Object.keys(config.dev.env).forEach((key) => { + if (!process.env[key]) { + process.env[key] = JSON.parse(config.dev.env[key]); + } +}); + +var opn = require('opn') +var path = require('path') +var express = require('express') +var webpack = require('webpack') +var proxyMiddleware = require('http-proxy-middleware') +var webpackConfig = require('./webpack.dev.conf') + +// default port where dev server listens for incoming traffic +var port = process.env.PORT || config.dev.port +// automatically open browser, if not set will be false +var autoOpenBrowser = !!config.dev.autoOpenBrowser +// Define HTTP proxies to your custom API backend +// https://github.com/chimurai/http-proxy-middleware +var proxyTable = config.dev.proxyTable + +var app = express() +var compiler = webpack(webpackConfig) + +// StackEdit custom middlewares +require('../server')(app); + +var devMiddleware = require('webpack-dev-middleware')(compiler, { + publicPath: webpackConfig.output.publicPath, + quiet: true +}) + +var hotMiddleware = require('webpack-hot-middleware')(compiler, { + log: () => {} +}) +// force page reload when html-webpack-plugin template changes +compiler.plugin('compilation', function (compilation) { + compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { + hotMiddleware.publish({ action: 'reload' }) + cb() + }) +}) + +// proxy api requests +Object.keys(proxyTable).forEach(function (context) { + var options = proxyTable[context] + if (typeof options === 'string') { + options = { target: options } + } + app.use(proxyMiddleware(options.filter || context, options)) +}) + +// handle fallback for HTML5 history API +app.use(require('connect-history-api-fallback')()) + +// serve webpack bundle output +app.use(devMiddleware) + +// enable hot-reload and state-preserving +// compilation error display +app.use(hotMiddleware) + +// serve pure static assets +var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) +app.use(staticPath, express.static('./static')) + +var uri = 'http://localhost:' + port + +var _resolve +var readyPromise = new Promise(resolve => { + _resolve = resolve +}) + +console.log('> Starting dev server...') +devMiddleware.waitUntilValid(() => { + console.log('> Listening at ' + uri + '\n') + // when env is testing, don't need open it + if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { + opn(uri) + } + _resolve() +}) + +var server = app.listen(port) + +module.exports = { + ready: readyPromise, + close: () => { + server.close() + } +} diff --git a/build/utils.js b/build/utils.js new file mode 100644 index 0000000..b1d54b4 --- /dev/null +++ b/build/utils.js @@ -0,0 +1,71 @@ +var path = require('path') +var config = require('../config') +var ExtractTextPlugin = require('extract-text-webpack-plugin') + +exports.assetsPath = function (_path) { + var assetsSubDirectory = process.env.NODE_ENV === 'production' + ? config.build.assetsSubDirectory + : config.dev.assetsSubDirectory + return path.posix.join(assetsSubDirectory, _path) +} + +exports.cssLoaders = function (options) { + options = options || {} + + var cssLoader = { + loader: 'css-loader', + options: { + minimize: process.env.NODE_ENV === 'production', + sourceMap: options.sourceMap + } + } + + // generate loader string to be used with extract text plugin + function generateLoaders (loader, loaderOptions) { + var loaders = [cssLoader] + if (loader) { + loaders.push({ + loader: loader + '-loader', + options: Object.assign({}, loaderOptions, { + sourceMap: options.sourceMap + }) + }) + } + + // Extract CSS when that option is specified + // (which is the case during production build) + if (options.extract) { + return ExtractTextPlugin.extract({ + use: loaders, + fallback: 'vue-style-loader' + }) + } else { + return ['vue-style-loader'].concat(loaders) + } + } + + // https://vue-loader.vuejs.org/en/configurations/extract-css.html + return { + css: generateLoaders(), + postcss: generateLoaders(), + less: generateLoaders('less'), + sass: generateLoaders('sass', { indentedSyntax: true }), + scss: generateLoaders('sass'), + stylus: generateLoaders('stylus'), + styl: generateLoaders('stylus') + } +} + +// Generate loaders for standalone style files (outside of .vue) +exports.styleLoaders = function (options) { + var output = [] + var loaders = exports.cssLoaders(options) + for (var extension in loaders) { + var loader = loaders[extension] + output.push({ + test: new RegExp('\\.' + extension + '$'), + use: loader + }) + } + return output +} diff --git a/build/vue-loader.conf.js b/build/vue-loader.conf.js new file mode 100644 index 0000000..7aee79b --- /dev/null +++ b/build/vue-loader.conf.js @@ -0,0 +1,12 @@ +var utils = require('./utils') +var config = require('../config') +var isProduction = process.env.NODE_ENV === 'production' + +module.exports = { + loaders: utils.cssLoaders({ + sourceMap: isProduction + ? config.build.productionSourceMap + : config.dev.cssSourceMap, + extract: isProduction + }) +} diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js new file mode 100644 index 0000000..f0f27e4 --- /dev/null +++ b/build/webpack.base.conf.js @@ -0,0 +1,109 @@ +var path = require('path') +var webpack = require('webpack') +var utils = require('./utils') +var config = require('../config') +var VueLoaderPlugin = require('vue-loader/lib/plugin') +var vueLoaderConfig = require('./vue-loader.conf') +var StylelintPlugin = require('stylelint-webpack-plugin') + +function resolve (dir) { + return path.join(__dirname, '..', dir) +} + +module.exports = { + entry: { + app: './src/' + }, + node: { + // For mermaid + fs: 'empty' // jison generated code requires 'fs' + }, + output: { + path: config.build.assetsRoot, + filename: '[name].js', + publicPath: process.env.NODE_ENV === 'production' + ? config.build.assetsPublicPath + : config.dev.assetsPublicPath + }, + resolve: { + extensions: ['.js', '.vue', '.json'], + alias: { + '@': resolve('src') + } + }, + module: { + rules: [ + { + test: /\.(js|vue)$/, + loader: 'eslint-loader', + enforce: 'pre', + include: [resolve('src'), resolve('test')], + options: { + formatter: require('eslint-friendly-formatter') + } + }, + { + test: /\.vue$/, + loader: 'vue-loader', + options: vueLoaderConfig + }, + // We can't pass graphlibrary to babel + { + test: /\.js$/, + loader: 'string-replace-loader', + include: [ + resolve('node_modules/graphlibrary') + ], + options: { + search: '^\\s*(?:let|const) ', + replace: 'var ', + flags: 'gm' + } + }, + { + test: /\.js$/, + loader: 'babel-loader', + include: [ + resolve('src'), + resolve('test'), + resolve('node_modules/mermaid') + ], + exclude: [ + resolve('node_modules/mermaid/src/diagrams/class/parser'), + resolve('node_modules/mermaid/src/diagrams/flowchart/parser'), + resolve('node_modules/mermaid/src/diagrams/gantt/parser'), + resolve('node_modules/mermaid/src/diagrams/git/parser'), + resolve('node_modules/mermaid/src/diagrams/sequence/parser') + ], + }, + { + test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, + loader: 'url-loader', + options: { + limit: 10000, + name: utils.assetsPath('img/[name].[hash:7].[ext]') + } + }, + { + test: /\.(ttf|eot|otf|woff2?)(\?.*)?$/, + loader: 'file-loader', + options: { + name: utils.assetsPath('fonts/[name].[hash:7].[ext]') + } + }, + { + test: /\.(md|yml|html)$/, + loader: 'raw-loader' + } + ] + }, + plugins: [ + new VueLoaderPlugin(), + new StylelintPlugin({ + files: ['**/*.vue', '**/*.scss'] + }), + new webpack.DefinePlugin({ + VERSION: JSON.stringify(require('../package.json').version) + }) + ] +} diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js new file mode 100644 index 0000000..6ec2eef --- /dev/null +++ b/build/webpack.dev.conf.js @@ -0,0 +1,35 @@ +var utils = require('./utils') +var webpack = require('webpack') +var config = require('../config') +var merge = require('webpack-merge') +var baseWebpackConfig = require('./webpack.base.conf') +var HtmlWebpackPlugin = require('html-webpack-plugin') +var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') + +// add hot-reload related code to entry chunks +Object.keys(baseWebpackConfig.entry).forEach(function (name) { + baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) +}) + +module.exports = merge(baseWebpackConfig, { + module: { + rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) + }, + // cheap-module-eval-source-map is faster for development + devtool: 'source-map', + plugins: [ + new webpack.DefinePlugin({ + NODE_ENV: config.dev.env.NODE_ENV + }), + // https://github.com/glenjamin/webpack-hot-middleware#installation--usage + new webpack.HotModuleReplacementPlugin(), + new webpack.NoEmitOnErrorsPlugin(), + // https://github.com/ampedandwired/html-webpack-plugin + new HtmlWebpackPlugin({ + filename: 'index.html', + template: 'index.html', + inject: true + }), + new FriendlyErrorsPlugin() + ] +}) diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js new file mode 100644 index 0000000..94da9d8 --- /dev/null +++ b/build/webpack.prod.conf.js @@ -0,0 +1,154 @@ +var path = require('path') +var utils = require('./utils') +var webpack = require('webpack') +var config = require('../config') +var merge = require('webpack-merge') +var baseWebpackConfig = require('./webpack.base.conf') +var CopyWebpackPlugin = require('copy-webpack-plugin') +var HtmlWebpackPlugin = require('html-webpack-plugin') +var ExtractTextPlugin = require('extract-text-webpack-plugin') +var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') +var OfflinePlugin = require('offline-plugin'); +var WebpackPwaManifest = require('webpack-pwa-manifest') +var FaviconsWebpackPlugin = require('favicons-webpack-plugin') + +function resolve (dir) { + return path.join(__dirname, '..', dir) +} + +var env = config.build.env + +var webpackConfig = merge(baseWebpackConfig, { + module: { + rules: utils.styleLoaders({ + sourceMap: config.build.productionSourceMap, + extract: true + }) + }, + devtool: config.build.productionSourceMap ? '#source-map' : false, + output: { + path: config.build.assetsRoot, + filename: utils.assetsPath('js/[name].[chunkhash].js'), + chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') + }, + plugins: [ + // http://vuejs.github.io/vue-loader/en/workflow/production.html + new webpack.DefinePlugin({ + NODE_ENV: env.NODE_ENV, + GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID, + GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID + }), + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false + }, + sourceMap: true + }), + // extract css into its own file + new ExtractTextPlugin({ + filename: utils.assetsPath('css/[name].[contenthash].css') + }), + // Compress extracted CSS. We are using this plugin so that possible + // duplicated CSS from different components can be deduped. + new OptimizeCSSPlugin({ + cssProcessorOptions: { + safe: true + } + }), + // generate dist index.html with correct asset hash for caching. + // you can customize output by editing /index.html + // see https://github.com/ampedandwired/html-webpack-plugin + new HtmlWebpackPlugin({ + filename: config.build.index, + template: 'index.html', + inject: true, + minify: { + removeComments: true, + collapseWhitespace: true, + removeAttributeQuotes: true + // more options: + // https://github.com/kangax/html-minifier#options-quick-reference + }, + // necessary to consistently work with multiple chunks via CommonsChunkPlugin + chunksSortMode: 'dependency' + }), + // split vendor js into its own file + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + minChunks: function (module, count) { + // any required modules inside node_modules are extracted to vendor + return ( + module.resource && + /\.js$/.test(module.resource) && + module.resource.indexOf( + path.join(__dirname, '../node_modules') + ) === 0 + ) + } + }), + // extract webpack runtime and module manifest to its own file in order to + // prevent vendor hash from being updated whenever app bundle is updated + new webpack.optimize.CommonsChunkPlugin({ + name: 'manifest', + chunks: ['vendor'] + }), + // copy custom static assets + new CopyWebpackPlugin([ + { + from: path.resolve(__dirname, '../static'), + to: config.build.assetsSubDirectory, + ignore: ['.*'] + } + ]), + new FaviconsWebpackPlugin({ + logo: resolve('src/assets/favicon.png'), + title: 'StackEdit', + }), + new WebpackPwaManifest({ + name: 'StackEdit', + description: 'Full-featured, open-source Markdown editor', + display: 'standalone', + orientation: 'any', + start_url: 'app', + background_color: '#ffffff', + crossorigin: 'use-credentials', + icons: [{ + src: resolve('src/assets/favicon.png'), + sizes: [96, 128, 192, 256, 384, 512] + }] + }), + new OfflinePlugin({ + ServiceWorker: { + events: true + }, + AppCache: true, + excludes: ['**/.*', '**/*.map', '**/index.html', '**/static/oauth2/callback.html', '**/icons-*/*.png', '**/static/fonts/KaTeX_*'], + externals: ['/', '/app', '/oauth2/callback'] + }), + ] +}) + +if (config.build.productionGzip) { + var CompressionWebpackPlugin = require('compression-webpack-plugin') + + webpackConfig.plugins.push( + new CompressionWebpackPlugin({ + asset: '[path].gz[query]', + algorithm: 'gzip', + test: new RegExp( + '\\.(' + + config.build.productionGzipExtensions.join('|') + + ')$' + ), + threshold: 10240, + minRatio: 0.8 + }) + ) +} + +if (config.build.bundleAnalyzerReport) { + var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin + webpackConfig.plugins.push(new BundleAnalyzerPlugin()) +} + +module.exports = webpackConfig diff --git a/build/webpack.style.conf.js b/build/webpack.style.conf.js new file mode 100644 index 0000000..4f69e8a --- /dev/null +++ b/build/webpack.style.conf.js @@ -0,0 +1,56 @@ +var path = require('path') +var utils = require('./utils') +var webpack = require('webpack') +var utils = require('./utils') +var config = require('../config') +var vueLoaderConfig = require('./vue-loader.conf') +var StylelintPlugin = require('stylelint-webpack-plugin') +var ExtractTextPlugin = require('extract-text-webpack-plugin') +var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') + +function resolve (dir) { + return path.join(__dirname, '..', dir) +} + +module.exports = { + entry: { + style: './src/styles/' + }, + module: { + rules: [{ + test: /\.(ttf|eot|otf|woff2?)(\?.*)?$/, + loader: 'file-loader', + options: { + name: utils.assetsPath('fonts/[name].[hash:7].[ext]') + } + }] + .concat(utils.styleLoaders({ + sourceMap: config.build.productionSourceMap, + extract: true + })), + }, + output: { + path: config.build.assetsRoot, + filename: '[name].js', + publicPath: config.build.assetsPublicPath + }, + plugins: [ + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false + }, + sourceMap: true + }), + // extract css into its own file + new ExtractTextPlugin({ + filename: '[name].css', + }), + // Compress extracted CSS. We are using this plugin so that possible + // duplicated CSS from different components can be deduped. + new OptimizeCSSPlugin({ + cssProcessorOptions: { + safe: true + } + }), + ] +} diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 0000000..50af031 --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..47e9e25 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: vSTACKEDIT_VERSION +description: In-browser Markdown editor +name: stackedit +version: STACKEDIT_VERSION diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..afce052 --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "stackedit.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "stackedit.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "stackedit.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "stackedit.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..dd36306 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,45 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "stackedit.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "stackedit.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "stackedit.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "stackedit.labels" -}} +app.kubernetes.io/name: {{ include "stackedit.name" . }} +helm.sh/chart: {{ include "stackedit.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..530040c --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "stackedit.fullname" . }} + labels: +{{ include "stackedit.labels" . | indent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "stackedit.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "stackedit.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + volumeMounts: + - mountPath: /run + name: run-volume + - mountPath: /tmp + name: tmp-volume + env: + - name: PORT + value: "80" + - name: PAYPAL_RECEIVER_EMAIL + value: {{ .Values.paypalReceiverEmail }} + - name: AWS_ACCESS_KEY_ID + value: {{ .Values.awsAccessKeyId }} + - name: AWS_SECRET_ACCESS_KEY + value: {{ .Values.awsSecretAccessKey }} + - name: DROPBOX_APP_KEY + value: {{ .Values.dropboxAppKey }} + - name: DROPBOX_APP_KEY_FULL + value: {{ .Values.dropboxAppKeyFull }} + - name: GOOGLE_CLIENT_ID + value: {{ .Values.googleClientId }} + - name: GOOGLE_API_KEY + value: {{ .Values.googleApiKey }} + - name: GITHUB_CLIENT_ID + value: {{ .Values.githubClientId }} + - name: GITHUB_CLIENT_SECRET + value: {{ .Values.githubClientSecret }} + - name: WORDPRESS_CLIENT_ID + value: {{ .Values.wordpressClientId }} + - name: WORDPRESS_SECRET + value: {{ .Values.wordpressSecret }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: run-volume + emptyDir: {} + - name: tmp-volume + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..d079953 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,39 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "stackedit.fullname" . -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: +{{ include "stackedit.labels" . | indent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + pathType: Prefix + backend: + service: + name: {{ $fullName }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..378f8a8 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "stackedit.fullname" . }} + labels: +{{ include "stackedit.labels" . | indent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "stackedit.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/chart/templates/tests/test-connection.yaml b/chart/templates/tests/test-connection.yaml new file mode 100644 index 0000000..ad2900c --- /dev/null +++ b/chart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "stackedit.fullname" . }}-test-connection" + labels: +{{ include "stackedit.labels" . | indent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "stackedit.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..414aaa2 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,71 @@ +# Default values for stackedit. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +dropboxAppKey: "" +dropboxAppKeyFull: "" +googleClientId: "" +googleApiKey: "" +githubClientId: "" +githubClientSecret: "" +giteeClientId: "" +giteeClientSecret: "" +wordpressClientId: "" +wordpressSecret: "" +paypalReceiverEmail: "" +awsAccessKeyId: "" +awsSecretAccessKey: "" +giteaClientId: "" +giteaClientSecret: "" +giteaUrl: "" +gitlabClientId: "" +gitlabUrl: "" + +replicaCount: 1 + +image: + repository: benweet/stackedit + tag: vSTACKEDIT_VERSION + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: + # kubernetes.io/ingress.class: nginx + # certmanager.k8s.io/issuer: letsencrypt-prod + # certmanager.k8s.io/acme-challenge-type: http01 + hosts: [] + # - host: stackedit.example.com + # paths: + # - / + + tls: [] + # - secretName: stackedit-tls + # hosts: + # - stackedit.example.com + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/chrome-app/icon-128.png b/chrome-app/icon-128.png new file mode 100644 index 0000000..498cb92 Binary files /dev/null and b/chrome-app/icon-128.png differ diff --git a/chrome-app/icon-16.png b/chrome-app/icon-16.png new file mode 100644 index 0000000..6432eb3 Binary files /dev/null and b/chrome-app/icon-16.png differ diff --git a/chrome-app/icon-256.png b/chrome-app/icon-256.png new file mode 100644 index 0000000..6e6c505 Binary files /dev/null and b/chrome-app/icon-256.png differ diff --git a/chrome-app/icon-32.png b/chrome-app/icon-32.png new file mode 100644 index 0000000..1c719cb Binary files /dev/null and b/chrome-app/icon-32.png differ diff --git a/chrome-app/icon-512.png b/chrome-app/icon-512.png new file mode 100644 index 0000000..8560f2e Binary files /dev/null and b/chrome-app/icon-512.png differ diff --git a/chrome-app/icon-64.png b/chrome-app/icon-64.png new file mode 100644 index 0000000..f16c067 Binary files /dev/null and b/chrome-app/icon-64.png differ diff --git a/chrome-app/manifest.json b/chrome-app/manifest.json new file mode 100644 index 0000000..afce6ee --- /dev/null +++ b/chrome-app/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "StackEdit中文版", + "description": "支持Gitee仓库/粘贴图片自动上传的浏览器内 Markdown 编辑器", + "version": "5.15.17", + "manifest_version": 2, + "container" : "GITEE", + "api_console_project_id" : "241271498917", + "icons": { + "16": "icon-16.png", + "32": "icon-32.png", + "64": "icon-64.png", + "128": "icon-128.png", + "256": "icon-256.png", + "512": "icon-512.png" + }, + "app": { + "urls": [ + "https://stackedit.cn/" + ], + "launch": { + "web_url": "https://stackedit.cn/app" + } + }, + "offline_enabled": true, + "permissions": [ + "unlimitedStorage" + ] +} diff --git a/config/dev.env.js b/config/dev.env.js new file mode 100644 index 0000000..4dc35d4 --- /dev/null +++ b/config/dev.env.js @@ -0,0 +1,18 @@ +var merge = require('webpack-merge') +var prodEnv = require('./prod.env') + +module.exports = merge(prodEnv, { + NODE_ENV: '"development"', + // 以下配置是开发临时用的配置 随时可能失效 请替换为自己的 + GITHUB_CLIENT_ID: '"845b8f75df48f2ee0563"', + GITHUB_CLIENT_SECRET: '"80df676597abded1450926861965cc3f9bead6a0"', + GITEE_CLIENT_ID: '"925ba7c78b85dec984f7877e4aca5cab10ae333c6d68e761bdb0b9dfb8f55672"', + GITEE_CLIENT_SECRET: '"f05731066e42d307339dc8ebbb037a103881dafc7207a359a393b87749f1c562"', + CLIENT_ID: '"thF3qCGLN39OtafjGnqHyj6n02WwE6xD"', + // GITEA_CLIENT_ID: '"fe30f8f9-b1e8-4531-8f72-c1a5d3912805"', + // GITEA_CLIENT_SECRET: '"lus7oMnb3H6M1hsChndphArE20Txr7erwJLf7SDBQWTw"', + // GITEA_URL: '"https://gitea.test.com"', + GITLAB_CLIENT_ID: '"074cd5103c62dea0f479dac861039656ac80935e304c8113a02cc64c629496ae"', + GITLAB_CLIENT_SECRET: '"6f406f24216b686d55d28313dec1913c2a8e599afdb08380d5e8ce838e16e41e"', + GITLAB_URL: '"http://gitlab.qicoder.com"', +}) \ No newline at end of file diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..8353371 --- /dev/null +++ b/config/index.js @@ -0,0 +1,39 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + build: { + env: require('./prod.env'), + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '/', + productionSourceMap: true, + // Gzip off by default as many popular static hosts such as + // Surge or Netlify already gzip all static assets for you. + // Before setting to `true`, make sure to: + // npm install --save-dev compression-webpack-plugin + productionGzip: false, + productionGzipExtensions: ['js', 'css'], + // Run the build command with an extra argument to + // View the bundle analyzer report after build finishes: + // `npm run build --report` + // Set to `true` or `false` to always turn it on or off + bundleAnalyzerReport: process.env.npm_config_report + }, + dev: { + env: require('./dev.env'), + port: 80, + autoOpenBrowser: false, + assetsSubDirectory: 'static', + assetsPublicPath: '/', + proxyTable: {}, + // CSS Sourcemaps off by default because relative paths are "buggy" + // with this option, according to the CSS-Loader README + // (https://github.com/webpack/css-loader#sourcemaps) + // In our experience, they generally work as expected, + // just be aware of this issue when enabling this option. + // cssSourceMap: false + cssSourceMap: true + } +} diff --git a/config/prod.env.js b/config/prod.env.js new file mode 100644 index 0000000..773d263 --- /dev/null +++ b/config/prod.env.js @@ -0,0 +1,3 @@ +module.exports = { + NODE_ENV: '"production"' +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..d8fa8ec --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,20 @@ +const path = require('path'); +const gulp = require('gulp'); +const concat = require('gulp-concat'); + +const prismScripts = [ + 'prismjs/components/prism-core', + 'prismjs/components/prism-markup', + 'prismjs/components/prism-clike', + 'prismjs/components/prism-c', + 'prismjs/components/prism-javascript', + 'prismjs/components/prism-css', + 'prismjs/components/prism-ruby', + 'prismjs/components/prism-cpp', +].map(require.resolve); +prismScripts.push( + path.join(path.dirname(require.resolve('prismjs/components/prism-core')), 'prism-!(*.min).js')); + +gulp.task('build-prism', () => gulp.src(prismScripts) + .pipe(concat('prism.js')) + .pipe(gulp.dest(path.dirname(require.resolve('prismjs'))))); diff --git a/index.html b/index.html new file mode 100644 index 0000000..3c4653c --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + +
+ +新的StackEdit中文版在这里!
+请单击下一步快速浏览。
+ +StackEdit中文版实时将Markdown转换为HTML。
+点击
StackEdit中文版可以管理文档空间中的多个文件和文件夹。
+点击
StackEdit中文版还可以同步和发布文件,管理协作文档空间...
+点击
StackEdit中文版可以切换亮/暗主题。
+点击
如果您喜欢StackEdit中文版,请在Gitee仓库上点一下Star,谢谢!
+ +StackEdit中文版可以访问以下外部账号:
+StackEdit中文版尚未访问任何外部账号。
+获得了{{badgeCount}}徽章
+获得了{{badgeCount}}徽章
+ChatGPT内容生成
生成时长受ChatGPT服务响应与网络响应时长影响,时间可能较长
(等待生成中...)+ +
自定义 {{ config.name }} 提交信息。
+提示: 您可以手动切换扩展名:
+extensions:
+ emoji:
+ # 启用表情符号快捷方式如 :) :-(
+ shortcuts: true
+
+ 使用预设zero
制作自己的配置:
extensions:
+ preset: zero
+ markdown:
+ table: true
+ katex:
+ enabled: true
+
+ 有关选项的完整列表,请参阅 这里.
+请为您的 HTML导出选择模板。
+请为您的图像提供 url 。(图片上传中...)
+添加并选择图床后可在编辑区中粘贴/拖拽图片自动上传
+ +请为您的链接提供 url 。
+请为您的 pandoc导出选择格式。
+请为您的 pdf导出选择模板。(该导出很消耗服务器资源,文档太大或图片太多可能会导出超时失败!可参考 大文档导出PDF方式 自行导出大文档!)
+{{currentFileName}} 被发布到了以下位置:
+{{currentFileName}} 还没有被发布.
+Please choose a PayPal option:
+ +{{currentFileName}} 与以下位置同步:
+{{currentFileName}}尚未同步。
+在当前文档空间增加图片上传路径。
+
可以访问以下文档空间:
请提供您的凭据,以登录CouchDB。
+创建一个与CouchDB数据库同步的文档空间。
+将您的自定义图床账号链接到StackEdit中文版。
+将您的Dropbox链接到StackEdit中文版。
+发布到您的Dropbox。
+将 {{CurrentFileName}} 保存到您的Dropbox并保持同步。
+发布 {{CurrentFileName}} 到GitHubGist。
+title
in the file properties.
+ 将 {{currentFileName}} 保存到GitHubGist并保持同步。
+创建一个用于存储图片的 Gitea 公开仓库文件夹图床。
+ master
分支。
+ 从您的Gitea项目中打开文件,并保持同步。
+ master
分支。
+ 向您的 Gitea 项目发布 {{ currentFileName }} 。
+ master
分支。
+ 将 {{currentFileName}} 保存到 Gitea 项目,并保持同步。
+ master
分支。
+ 创建一个与 Gitea 仓库文件夹同步的文档空间。
+ master
分支。
+ 将您的Gitee账号链接到StackEdit中文版。
+发布 {{CurrentFileName}} 到GiteeGist。
+title
in the file properties.
+ 将 {{currentFileName}} 保存到GiteeGist并保持同步。
+从您的 Gitee 仓库中打开文件,并保持同步。
+ master
分支。
+ 发布 {{currentFileName}} 到您的 Gitee 仓库.
+ master
分支。
+ 保存 {{currentFileName}} 并与您的 Gitee 仓库保持同步.
+ master
分支。
+ 创建一个与Gitee仓库文件夹同步的文档空间。
+ master
分支。
+ 将您的Github账号链接到StackEdit中文版。
+创建一个用于存储图片的 GitHub 公开仓库文件夹图床。
+ master
分支。
+ 从您的GitHub repository and keep it synced.
+ master
分支。
+ 发布 {{currentFileName}} 到您的 GitHub 仓库.
+ master
分支。
+ Save {{currentFileName}} to your GitHub repository and keep it synced.
+ master
分支。
+ 创建一个与GitHub仓库文件夹同步的文档空间。
+ master
分支。
+ 从您的GitLab项目中打开文件,并保持同步。
+ master
分支。
+ 发布 {{currentFileName}} 到您的 GitLab 仓库.
+ master
分支。
+ Save {{currentFileName}} to your GitLab项目中打开文件,并保持同步。
+ master
分支。
+ 创建一个与GitLab仓库文件夹同步的文档空间。
+ master
分支。
+ 将您的Google Drive链接到StackEdit中文版。
+发布 {{currentFileName}} 到您的 Google Drive 账号.
+title
in the file properties.
+ Save {{currentFileName}} to your Google Drive account and keep it synced.
+创建一个与 Google Drive 文件夹同步的文档空间。
+将您的SM.MS账号链接到StackEdit中文版。
+将您的Zendesk链接到StackEdit中文版。
+]*>/g, '') + ); +}); + +Then use the helper in your template: + +{{#transform}}{{{files.0.content.html}}}{{/transform}} +*/ + diff --git a/src/data/empties/emptyTemplateValue.html b/src/data/empties/emptyTemplateValue.html new file mode 100644 index 0000000..189e9cc --- /dev/null +++ b/src/data/empties/emptyTemplateValue.html @@ -0,0 +1,38 @@ + + diff --git a/src/data/faq.md b/src/data/faq.md new file mode 100644 index 0000000..65a4d6b --- /dev/null +++ b/src/data/faq.md @@ -0,0 +1,9 @@ +**我的数据存储在哪里?** + +如果您的文档空间没有同步,则文件存储在浏览器中,无处可寻。 + +我们建议同步您的文档空间,以确保在清除浏览器数据的情况下不会丢失文件。自托管Gitea后端非常适合保证隐私。 + +**StackEdit中文版可以访问我的数据而不告诉我吗?** + +StackEdit中文版是一个基于浏览器的应用程序。Gitee,Github,Dropbox ...发出的访问令牌存储在您的浏览器中,不会发送到任何形式的后端或第三方,因此任何人都不会访问您的数据。 diff --git a/src/data/features.js b/src/data/features.js new file mode 100644 index 0000000..0baf337 --- /dev/null +++ b/src/data/features.js @@ -0,0 +1,622 @@ +class Feature { + constructor(id, badgeName, description, children = null) { + this.id = id; + this.badgeName = badgeName; + this.description = description; + this.children = children; + } + + toBadge(badgeCreations) { + const children = this.children + ? this.children.map(child => child.toBadge(badgeCreations)) + : null; + return { + featureId: this.id, + name: this.badgeName, + description: this.description, + children, + isEarned: children + ? children.every(child => child.isEarned) + : !!badgeCreations[this.id], + hasSomeEarned: children && children.some(child => child.isEarned), + }; + } +} + +export default [ + new Feature( + 'navigationBar', + '丰富的导航栏', + '通过格式化一些Markdown和重命名当前文件掌握导航栏', + [ + new Feature( + 'formatButtons', + '格式化', + '使用格式化按钮更改 Markdown 文件中的格式。', + ), + new Feature( + 'editCurrentFileName', + '重命名', + '使用导航栏中的名称字段重命名当前文件。', + ), + new Feature( + 'toggleExplorer', + '资源管理器切换', + '使用导航栏切换资源管理器。', + ), + new Feature( + 'toggleSideBar', + '切换侧边栏', + '使用导航栏来切换侧边栏。', + ), + ], + ), + new Feature( + 'explorer', + '资源管理器', + '使用文件资源管理器管理文档空间中的文件和文件夹。', + [ + new Feature( + 'createFile', + '文件创建', + '使用文件资源管理器在文档空间中创建一个新文件。', + ), + new Feature( + 'switchFile', + '文件切换', + '使用文件资源管理器在文档空间中从一个文件切换到另一个文件。', + ), + new Feature( + 'createFolder', + '文件夹创建', + '使用文件资源管理器在文档空间中创建一个新文件夹。', + ), + new Feature( + 'moveFile', + '文件移动', + '在文件管理器中拖动一个文件到另一个文件夹。', + ), + new Feature( + 'moveFolder', + '文件夹移动', + '在文件管理器中拖动一个文件夹到另一个文件夹。', + ), + new Feature( + 'renameFile', + '文件重命名', + '使用文件资源管理器重命名文档空间中的文件。', + ), + new Feature( + 'renameFolder', + '文件夹重命名', + '使用文件资源管理器重命名文档空间中的文件夹。', + ), + new Feature( + 'removeFile', + '文件删除', + '使用文件资源管理器删除文档空间中的文件。', + ), + new Feature( + 'removeFolder', + '文件夹删除', + '使用文件资源管理器删除文档空间中的文件夹。', + ), + new Feature( + 'searchFile', + '文件搜索', + '使用文件资源管理器搜索文档空间中的文件。', + ), + ], + ), + new Feature( + 'buttonBar', + '按钮栏', + '使用按钮栏自定义编辑器布局并切换功能。', + [ + new Feature( + 'toggleNavigationBar', + '导航栏切换', + '使用按钮栏切换导航栏。', + ), + new Feature( + 'toggleSidePreview', + '切换侧边预览', + '使用按钮栏切换侧边预览。', + ), + new Feature( + 'toggleEditor', + '切换编辑器', + '使用按钮栏切换编辑器。', + ), + new Feature( + 'toggleFocusMode', + '切换焦点模式', + '使用按钮栏切换焦点模式。此模式在键入时将其垂直居中。', + ), + new Feature( + 'toggleScrollSync', + '换滚动同步', + '使用按钮栏切换滚动同步功能。此功能链接编辑器和预览滚动条。', + ), + new Feature( + 'toggleStatusBar', + '状态栏切换器', + '使用按钮栏切换状态栏。', + ), + ], + ), + new Feature( + 'signIn', + '登录', + '使用 Gitee 登录,同步您的主文档空间并解锁功能。', + [ + new Feature( + 'syncMainWorkspace', + '主文档空间已同步', + '使用 Gitee 登录以将您的主文档空间与您的默认空间stackedit-app-data仓库数据同步。', + ), + new Feature( + 'sponsor', + '赞助', + '使用 Gitee 登录并赞助 StackEdit 以解锁 PDF 和 Pandoc 导出。(暂不支持赞助)', + ), + ], + ), + new Feature( + 'githubSignIn', + '登录', + '使用 Gitee 登录,同步您的主文档空间并解锁功能。', + [ + new Feature( + 'githubSyncMainWorkspace', + '主文档空间已同步', + '使用 GitHub 登录以将您的主文档空间与您的默认空间stackedit-app-data仓库数据同步。', + ), + ], + ), + new Feature( + 'workspaces', + '文档空间菜单', + '使用文档空间菜单创建各种文档空间并对其进行管理。', + [ + new Feature( + 'addCouchdbWorkspace', + '创建CouchDB文档空间', + '使用文档空间菜单创建CouchDB文档空间。', + ), + new Feature( + 'addGithubWorkspace', + '创建GitHub文档空间', + '使用文档空间菜单创建GitHub文档空间。', + ), + new Feature( + 'addGiteeWorkspace', + '创建Gitee文档空间', + '使用文档空间菜单创建Gitee文档空间。', + ), + new Feature( + 'addGitlabWorkspace', + '创建Gitlab文档空间', + '使用文档空间菜单创建GitLab文档空间。', + ), + new Feature( + 'addGiteaWorkspace', + '创建Gitea文档空间', + '使用文档空间菜单创建Gitea文档空间。', + ), + new Feature( + 'addGoogleDriveWorkspace', + '创建Google Drive文档空间', + '使用文档空间菜单创建Google Drive文档空间。', + ), + new Feature( + 'renameWorkspace', + '文档空间重命名', + '使用“管理文档空间”对话框重命名文档空间。', + ), + new Feature( + 'removeWorkspace', + '文档空间删除', + '使用“管理文档空间”对话框在本地删除文档空间。', + ), + new Feature( + 'autoSyncWorkspace', + '文档空间启用自动同步', + '使用“管理文档空间”对话框启用自动同步。', + ), + new Feature( + 'stopAutoSyncWorkspace', + '文档空间关闭自动同步', + '使用“管理文档空间”对话框关闭自动同步。', + ), + ], + ), + new Feature( + 'manageAccounts', + '账号管理', + '链接各种外部账号,并使用“账号”对话框来管理它们。', + [ + new Feature( + 'addBloggerAccount', + 'Blogger账号', + '将您的Blogger账号链接到StackEdit中文版。', + ), + new Feature( + 'addDropboxAccount', + 'Dropbox账号', + '将您的Dropbox账号链接到StackEdit中文版。', + ), + new Feature( + 'addGitHubAccount', + 'GitHub账号', + '将您的Github账号链接到StackEdit中文版。', + ), + new Feature( + 'addGiteeAccount', + 'Gitee账号', + '将您的Gitee账号链接到StackEdit中文版。', + ), + new Feature( + 'addGitLabAccount', + 'GitLab账号', + '将您的Gitlab账号链接到StackEdit中文版。', + ), + new Feature( + 'addGiteaAccount', + 'Gitea账号', + '将您的Gitea账号链接到StackEdit中文版。', + ), + new Feature( + 'addGoogleDriveAccount', + 'Google Drive账号', + '将您的Google Drive账号链接到StackEdit中文版。', + ), + new Feature( + 'addGooglePhotosAccount', + 'Google Photos账号', + '将您的Google Photos账号链接到StackEdit中文版。', + ), + new Feature( + 'addWordpressAccount', + 'WordPress账号', + '将您的WordPress账号链接到StackEdit中文版。', + ), + new Feature( + 'addZendeskAccount', + 'Zendesk账号', + '将您的Zendesk账号链接到StackEdit中文版。', + ), + new Feature( + 'addSmmsAccount', + 'SM.MS账号', + '将您的SM.MS账号链接到StackEdit中文版。', + ), + new Feature( + 'addCustomAccount', + '自定义图床账号', + '将您的自定义图床账号链接到StackEdit中文版。', + ), + new Feature( + 'removeAccount', + '移除账号', + '使用“账号”对话框删除对外部账号的访问。', + ), + ], + ), + new Feature( + 'syncFiles', + '文件同步器', + '通过打开和保存各种外部账号的文件来掌握“同步”菜单。', + [ + new Feature( + 'openFromDropbox', + 'Dropbox阅读器', + '使用“同步”菜单从您的Dropbox账号打开文件。', + ), + new Feature( + 'saveOnDropbox', + 'Dropbox保存', + '使用“同步”菜单将文件保存在您的Dropbox账号中。', + ), + new Feature( + 'openFromGithub', + 'Github阅读器', + '使用“同步”菜单从GitHub仓库打开文件。', + ), + new Feature( + 'saveOnGithub', + 'GitHub保存', + '使用“同步”菜单将文件保存在GitHub仓库中。', + ), + new Feature( + 'saveOnGist', + 'GitHubGist保存', + '使用“同步”菜单将文件保存在GitHubGist中。', + ), + new Feature( + 'openFromGitee', + 'Gitee阅读器', + '使用“同步”菜单从Gitee仓库打开文件。', + ), + new Feature( + 'saveOnGitee', + 'Gitee保存', + '使用“同步”菜单将文件保存在Gitee仓库中。', + ), + new Feature( + 'saveOnGiteeGist', + 'GiteeGist保存', + '使用“同步”菜单将文件保存在GiteeGist中。', + ), + new Feature( + 'openFromGitlab', + 'GitLab阅读器', + '使用“同步”菜单从GitLab仓库打开文件。', + ), + new Feature( + 'saveOnGitlab', + 'GitLab保存', + '使用“同步”菜单将文件保存在GitLab仓库中。', + ), + new Feature( + 'openFromGitea', + 'Gitea阅读器', + '使用“同步”菜单从Gitea仓库打开文件。', + ), + new Feature( + 'saveOnGitea', + 'Gitea保存', + '使用“同步”菜单将文件保存在Gitea仓库中。', + ), + new Feature( + 'openFromGoogleDrive', + 'Google Drive阅读器', + '使用“同步”菜单从您的Google Drive账号打开文件。', + ), + new Feature( + 'saveOnGoogleDrive', + 'Google Drive保存', + '使用“同步”菜单将文件保存在您的Google Drive账号中。', + ), + new Feature( + 'triggerSync', + '同步触发器', + '使用“同步”菜单或导航栏手动触发同步。', + ), + new Feature( + 'syncMultipleLocations', + '多方同步', + '使用“同步”菜单将文件与多个外部位置同步。', + ), + new Feature( + 'removeSyncLocation', + '删除同步', + '使用“文件同步”对话框删除同步位置。', + ), + ], + ), + new Feature( + 'publishFiles', + '文件发布', + '通过将文件发布到各种外部账号中来掌握“发布”菜单。', + [ + new Feature( + 'publishToBlogger', + 'Blogger发布', + '使用“发布”菜单发布博客文章。', + ), + new Feature( + 'publishToBloggerPage', + 'Blogger页面发布', + '使用“发布”菜单发布Blogger页面。', + ), + new Feature( + 'publishToDropbox', + 'Dropbox发布', + '使用“发布”菜单将文件发布到您的Dropbox账号。', + ), + new Feature( + 'publishToGithub', + 'GitHub发布', + '使用“发布”菜单将文件发布到GitHub仓库。', + ), + new Feature( + 'publishToGist', + 'GitHubGist发布', + '使用“发布”菜单将文件发布到GitHubGist。', + ), + new Feature( + 'publishToGitee', + 'Gitee发布', + '使用“发布”菜单将文件发布到Gitee仓库。', + ), + new Feature( + 'publishToGiteeGist', + 'GiteeGist发布', + '使用“发布”菜单将文件发布到GiteeGist。', + ), + new Feature( + 'publishToGitlab', + 'GitLab发布', + '使用“发布”菜单将文件发布到GitLab仓库中。', + ), + new Feature( + 'publishToGitea', + 'Gitea发布', + '使用“发布”菜单将文件发布到Gitea仓库。', + ), + new Feature( + 'publishToGoogleDrive', + 'Google Drive发布', + '使用“发布”菜单将文件发布到您的Google Drive账号。', + ), + new Feature( + 'publishToWordPress', + 'WordPress发布', + '使用“发布”菜单发布WordPress文章。', + ), + new Feature( + 'publishToZendesk', + 'Zendesk发布', + '使用“发布”菜单发布Zendesk帮助中心文章。', + ), + new Feature( + 'triggerPublish', + '更新发布', + '使用“发布”菜单或导航栏手动更新发布。', + ), + new Feature( + 'publishMultipleLocations', + '多方发布', + '使用“发布”菜单将文件发布到多个外部位置。', + ), + new Feature( + 'removePublishLocation', + '删除发布', + '使用“文件发布”对话框删除发布位置。', + ), + ], + ), + new Feature( + 'manageHistory', + '文件历史记录', + '使用“文件历史记录”菜单查看版本历史记录并恢复当前文件的旧版本。', + [ + new Feature( + 'restoreVersion', + '恢复', + '使用“文件历史记录”菜单来还原当前文件的旧版本。', + ), + new Feature( + 'chooseHistory', + '历史版本选择', + '选择与多个外部位置同步的文件的不同历史记录。', + ), + ], + ), + new Feature( + 'manageProperties', + '文件属性', + '使用“文件属性”对话框更改当前文件的属性。', + [ + new Feature( + 'setMetadata', + '元数据设置', + '使用“文件属性”对话框为当前文件设置元数据。', + ), + new Feature( + 'changePreset', + '预设更改', + '使用“文件属性”对话框更改Markdown引擎预设。', + ), + new Feature( + 'changeExtension', + '扩展配置', + '使用“文件属性”对话框启用,禁用或配置Markdown引擎扩展。', + ), + ], + ), + new Feature( + 'comment', + '评论', + '添加和删除批注,添加和删除评论。', + [ + new Feature( + 'createDiscussion', + '添加批注', + '使用“批注”按钮图标添加新的批注。', + ), + new Feature( + 'addComment', + '添加批注评论', + '使用“评论”按钮在现有批注中添加评论。', + ), + new Feature( + 'removeComment', + '删除批注评论', + '使用“删除评论”按钮图标删除批注评论。', + ), + new Feature( + 'removeDiscussion', + '删除批注', + '使用“删除批注”按钮图标删除批注。', + ), + ], + ), + new Feature( + 'importExport', + '导入/导出', + '使用“导入/导出”菜单以导入和导出文件。', + [ + new Feature( + 'importMarkdown', + 'Markdown导入', + '使用“导入/导出”菜单从磁盘导入Markdown文件。', + ), + new Feature( + 'exportMarkdown', + 'Markdown导出', + '使用“导入/导出”菜单将Markdown文件导出到磁盘。', + ), + new Feature( + 'importHtml', + 'HTML导入', + '使用“导入/导出”菜单从磁盘导入HTML文件,然后将其转换为Markdown。', + ), + new Feature( + 'exportHtml', + 'HTML导出', + '使用“导入/导出”菜单和Handlebars模板将文件导出到磁盘作为HTML文件。', + ), + new Feature( + 'exportPdf', + 'PDF导出', + '使用“导入/导出”菜单将文件导出到磁盘作为PDF文件。', + ), + new Feature( + 'exportPandoc', + 'Pandoc导出', + '使用“导入/导出”菜单将文件导出到使用Pandoc的磁盘。', + ), + ], + ), + new Feature( + 'manageSettings', + '管理设置', + '使用“设置”对话框调整应用程序行为并更改键盘快捷键。', + [ + new Feature( + 'changeSettings', + '更新设置', + '使用“设置”对话框调整应用程序行为。', + ), + new Feature( + 'switchTheme', + '切换主题', + '使用“主题切换”按钮切换主题。', + ), + new Feature( + 'changeShortcuts', + '编辑快捷键', + '使用“设置”对话框更改键盘快捷键。', + ), + ], + ), + new Feature( + 'manageTemplates', + '管理模板', + '使用“模板”对话框创建,删除或修改Handlebars模板。', + [ + new Feature( + 'addTemplate', + '模板创建', + '使用“模板”对话框创建一个Handlebars模板。', + ), + new Feature( + 'removeTemplate', + '模板删除', + '使用“模板”对话框删除Handlebars模板。', + ), + ], + ), +]; diff --git a/src/data/markdownSample.md b/src/data/markdownSample.md new file mode 100644 index 0000000..d5ca0bb --- /dev/null +++ b/src/data/markdownSample.md @@ -0,0 +1,129 @@ +标题 +--------------------------- + +# 标题1 + +## 标题2 + +### 标题3 + + + +样式 +--------------------------- + +*强调* _强调_ + +**加粗** __加粗__ + +==标记文本== + +~~删除线文本~~ + +> 块引用文本 + +H~2~O是一种液体 + +2^10^是1024 + + + +列表 +--------------------------- + +- 列表项 + * 列表项 + + 列表项 + +1. 列表项 1 +2. 列表项 2 +3. 列表项 3 + +- [ ] 未完成项 +- [x] 已完成项 + + + +链接 +--------------------------- + +一个[链接](http://example.com). + +一张图片:  + +一张调整大小的图片:  + + + +代码 +--------------------------- + +一些`行内代码`. + +``` +// 一个代码块 +var foo = 'bar'; +``` + +```javascript +// 一个高亮代码块 +var foo = 'bar'; +``` + + + +表格 +--------------------------- + +Item | Value +-------- | ----- +Computer | $1600 +Phone | $12 +Pipe | $1 + + +| Column 1 | Column 2 | +|:--------:| -------------:| +| centered | right-aligned | + + + +定义列表 +--------------------------- + +Markdown +: 文本到HTML转换工具 + +作者 +: 张三 +: 李四 + + + +脚注 +--------------------------- + +一些带有脚注的文本。[^1] + +[^1]: 脚注内容。 + + + +缩写 +--------------------------- + +Markdown将文本转换为 HTML。 + +*[HTML]: 超文本标记语言 + + + +LaTeX数学表达式 +--------------------------- + +满足 $\Gamma(n) = (n-1)!\quad\forall +n\in\mathbb N$ 的Gamma函数是通过欧拉积分 + +$$ +\Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. +$$ diff --git a/src/data/pagedownButtons.js b/src/data/pagedownButtons.js new file mode 100644 index 0000000..d952ee2 --- /dev/null +++ b/src/data/pagedownButtons.js @@ -0,0 +1,56 @@ +export default [{ +}, { + method: 'bold', + title: '加粗', + icon: 'format-bold', +}, { + method: 'italic', + title: '斜体', + icon: 'format-italic', +}, { + method: 'heading', + title: '标题', + icon: 'format-size', +}, { + method: 'strikethrough', + title: '删除线', + icon: 'format-strikethrough', +// }, { +}, { + method: 'ulist', + title: '无序列表', + icon: 'format-list-bulleted', +}, { + method: 'olist', + title: '有序列表', + icon: 'format-list-numbers', +}, { + method: 'clist', + title: '可选列表', + icon: 'format-list-checks', +// }, { +}, { + method: 'quote', + title: '块引用', + icon: 'format-quote-close', +}, { + method: 'code', + title: '代码', + icon: 'code-tags', +}, { + method: 'table', + title: '表格', + icon: 'table', +}, { + method: 'link', + title: '链接', + icon: 'link-variant', +}, { + method: 'image', + title: '图片', + icon: 'file-image', +}, { + method: 'chatgpt', + title: 'ChatGPT', + icon: 'chat-gpt', +}]; diff --git a/src/data/presets.js b/src/data/presets.js new file mode 100644 index 0000000..fb60a50 --- /dev/null +++ b/src/data/presets.js @@ -0,0 +1,114 @@ +const zero = { + // Markdown extensions + markdown: { + abbr: false, + breaks: false, + deflist: false, + del: false, + fence: false, + footnote: false, + imgsize: false, + linkify: false, + mark: false, + sub: false, + sup: false, + table: false, + tasklist: false, + typographer: false, + toc: false, + }, + // Emoji extension + emoji: { + enabled: false, + // Enable shortcuts like :) :-( + shortcuts: false, + }, + /* + ABC Notation extension + Render abc-notation code blocks to music sheets + See https://abcjs.net/ + */ + abc: { + enabled: false, + }, + /* + Katex extension + Render LaTeX mathematical expressions using: + $...$ for inline formulas + $$...$$ for displayed formulas. + See https://math.meta.stackexchange.com/questions/5020 + */ + katex: { + enabled: false, + }, + /* + Mermaid extension + Convert code blocks starting with ```mermaid + into diagrams and flowcharts. + See https://mermaidjs.github.io/ + */ + mermaid: { + enabled: false, + }, + /* + Toc extension + 把 [TOC] 转换为目录 + */ + toc: { + enabled: false, + }, +}; + +export default { + zero: [zero], + commonmark: [zero, { + markdown: { + fence: true, + }, + }], + gfm: [zero, { + markdown: { + breaks: true, + del: true, + fence: true, + linkify: true, + table: true, + tasklist: true, + toc: true, + }, + emoji: { + enabled: true, + }, + }], + default: [zero, { + markdown: { + abbr: true, + breaks: true, + deflist: true, + del: true, + fence: true, + footnote: true, + imgsize: true, + linkify: true, + mark: true, + sub: true, + sup: true, + table: true, + tasklist: true, + toc: true, + typographer: true, + }, + emoji: { + enabled: true, + }, + katex: { + enabled: true, + }, + mermaid: { + enabled: true, + }, + abc: { + enabled: true, + }, + }], +}; diff --git a/src/data/simpleModals.js b/src/data/simpleModals.js new file mode 100644 index 0000000..3628027 --- /dev/null +++ b/src/data/simpleModals.js @@ -0,0 +1,136 @@ +const simpleModal = (contentHtml, rejectText, resolveText, resolveArray) => ({ + contentHtml: typeof contentHtml === 'function' ? contentHtml : () => contentHtml, + rejectText, + resolveArray, + resolveText, +}); + +/* eslint sort-keys: "error" */ +export default { + autoSyncWorkspace: simpleModal( + config => `您将启动文档空间 ${config.name}的自动同步。
`, + '取消', + '确认启动', + ), + commentDeletion: simpleModal( + '
启动后无法自定义提交信息。
你确定吗?您将要删除评论。你确定吗?
', + '取消', + '确认删除', + ), + discussionDeletion: simpleModal( + '您将要删除批注。你确定吗?
', + '取消', + '确认删除', + ), + fileRestoration: simpleModal( + '您将要恢复一些更改。你确定吗?
', + '取消', + '确认恢复', + ), + folderDeletion: simpleModal( + config => `您将删除文件夹${config.item.name}。它的文件将移至回收站。你确定吗?
`, + '取消', + '确认删除', + ), + imgStorageDeletion: simpleModal( + '您将要删除图床,你确定吗?
', + '取消', + '确认删除', + ), + pathConflict: simpleModal( + config => `${config.item.name}已经存在。您要添加后缀吗?
`, + '取消', + '确认添加', + ), + paymentSuccess: simpleModal( + '感谢您的付款!
您的赞助将在一分钟内活跃。
', + '好的', + ), + providerRedirection: simpleModal( + config => `您将跳转到 ${config.name} 授权页面。
`, + '取消', + '确认跳转', + ), + removeWorkspace: simpleModal( + config => `您将要在本地删除文档空间${config.name}。你确定吗?
`, + '取消', + '确认删除', + ), + reset: simpleModal( + '这将在本地清理所有文档空间,你确定吗?
', + '取消', + '确认清理', + ), + shareHtml: simpleModal( + config => `给文档 "${config.name}" 创建了分享链接如下:
`, + '关闭窗口', + ), + shareHtmlPre: simpleModal( + config => `
${config.url}
关闭该窗口后可以到发布中查看分享链接。将给文档 "${config.name}" 创建分享链接,创建后将会把文档公开发布到默认空间账号的Gist中。您确定吗?
`, + '取消', + '确认分享', + ), + signInForComment: simpleModal( + `您必须使用 Gitee或GitHub 登录默认文档空间后才能开始评论。
+注意: 这将同步您的主文档空间。`, + '取消', + '', + [{ + text: 'Gitee登录', + value: 'gitee', + }, { + text: 'GitHub登录', + value: 'github', + }], + ), + signInForSponsorship: simpleModal( + `您必须使用 Gitee或GitHub 登录才能赞助。
+注意: 这将同步您的主文档空间。`, + '取消', + '', + [{ + text: 'Gitee登录', + value: 'gitee', + }, { + text: 'GitHub登录', + value: 'github', + }], + ), + sponsorOnly: simpleModal( + '此功能仅限于赞助商,因为它依赖于服务器资源。
', + '好的,我明白了', + ), + stopAutoSyncWorkspace: simpleModal( + config => `您将关闭文档空间 ${config.name} 的自动同步。
`, + '取消', + '确认关闭', + ), + stripName: simpleModal( + config => `
关闭后您需要手动触发同步,但可以自定义提交信息。
你确定吗?${config.item.name}包含非法字符。你想去掉它们吗?
`, + '取消', + '确认去掉', + ), + tempFileDeletion: simpleModal( + config => `您将永久删除临时文件${config.item.name}。你确定吗?
`, + '取消', + '确认删除', + ), + tempFolderDeletion: simpleModal( + '您将永久删除所有临时文件。你确定吗?
', + '取消', + '确认删除', + ), + trashDeletion: simpleModal( + '回收站中的文件在不活动7天后会自动删除。
', + '好的', + ), + unauthorizedName: simpleModal( + config => `${config.item.name}>是未经授权的名称。
`, + '好的', + ), + workspaceGoogleRedirection: simpleModal( + 'StackEdit中文版需要完整的Google Drive访问才能打开此文档空间。
', + '取消', + '确认授权', + ), +}; diff --git a/src/data/templates/jekyllSiteTemplate.html b/src/data/templates/jekyllSiteTemplate.html new file mode 100644 index 0000000..69da7fb --- /dev/null +++ b/src/data/templates/jekyllSiteTemplate.html @@ -0,0 +1,5 @@ +--- +{{{files.0.content.yamlProperties}}} +--- + +{{{files.0.content.html}}} diff --git a/src/data/templates/plainHtmlTemplate.html b/src/data/templates/plainHtmlTemplate.html new file mode 100644 index 0000000..42b6b5d --- /dev/null +++ b/src/data/templates/plainHtmlTemplate.html @@ -0,0 +1 @@ +{{{files.0.content.html}}} diff --git a/src/data/templates/styledHtmlTemplate.html b/src/data/templates/styledHtmlTemplate.html new file mode 100644 index 0000000..d6e3b59 --- /dev/null +++ b/src/data/templates/styledHtmlTemplate.html @@ -0,0 +1,19 @@ + + + + + + +{{files.0.name}} + + + +{{#if pdf}} + +{{else}} + +{{/if}} +{{{files.0.content.html}}}+ + + diff --git a/src/data/templates/styledHtmlWithThemeAndTocTemplate.html b/src/data/templates/styledHtmlWithThemeAndTocTemplate.html new file mode 100644 index 0000000..1ccb208 --- /dev/null +++ b/src/data/templates/styledHtmlWithThemeAndTocTemplate.html @@ -0,0 +1,43 @@ + + + + + + +{{files.0.name}} + + + + +{{#if pdf}} + +{{else}} + +{{/if}} ++++ {{#tocToHtml files.0.content.toc 2}}{{/tocToHtml}} ++++ + + diff --git a/src/data/templates/styledHtmlWithThemeTemplate.html b/src/data/templates/styledHtmlWithThemeTemplate.html new file mode 100644 index 0000000..78ede0c --- /dev/null +++ b/src/data/templates/styledHtmlWithThemeTemplate.html @@ -0,0 +1,36 @@ + + + + + + ++++ {{{files.0.content.html}}} ++{{files.0.name}} + + + + +{{#if pdf}} + +{{else}} + +{{/if}} +++ + + diff --git a/src/data/templates/styledHtmlWithTocTemplate.html b/src/data/templates/styledHtmlWithTocTemplate.html new file mode 100644 index 0000000..3952991 --- /dev/null +++ b/src/data/templates/styledHtmlWithTocTemplate.html @@ -0,0 +1,28 @@ + + + + + + ++ {{{files.0.content.html}}} ++{{files.0.name}} + + + +{{#if pdf}} + +{{else}} + +{{/if}} ++++ {{#tocToHtml files.0.content.toc 2}}{{/tocToHtml}} ++++ + + diff --git a/src/data/welcomeFile.md b/src/data/welcomeFile.md new file mode 100644 index 0000000..efa57c4 --- /dev/null +++ b/src/data/welcomeFile.md @@ -0,0 +1,145 @@ +# 欢迎来到 StackEdit 中文版! + +你好!我是你在 **StackEdit中文版** 中的第一个 Markdown 文件。如果你想了解 StackEdit中文版,可以阅读此文章。如果你想玩 Markdown,你也可以编辑此文章。另外,您可以通过打开导航栏左边的**文件资源管理器**来创建新文件。 + +# 文件 + +StackEdit中文版 将您的文件存储在您的浏览器中,这意味着您的所有文件都会自动保存在本地并且可以**离线访问!** + +## 创建文件和文件夹 + +使用导航栏左边的文件夹图标可以访问文件资源管理器。您可以通过单击文件资源管理器中的 **创建文件** 图标来创建新文件。您还可以通过单击 **创建文件夹** 图标来创建文件夹。 + +## 切换到另一个文件 + +您的所有文件和文件夹在文件资源管理器中都显示为树。您可以通过单击树中的文件从一个文件切换到另一个文件。 + +## 重命名文件 + +您可以通过单击导航栏中的文件名或单击文件资源管理器中的**重命名**图标来重命名当前文件。 + +## 搜索文件 + +您可以通过单击文件资源管理器中的**搜索文件**图标来通过关键字在整个文档空间中搜索文件。 + +## 删除一个文件 + +您可以通过单击文件资源管理器中的 **删除** 图标来删除当前文件。该文件将被移至 **回收站** 文件夹并在 7 天不活动后自动删除。 + +## 导出文件 + +您可以通过单击菜单中的 **导入/导出** 来导出当前文件。您可以选择将文件导出为纯 Markdown、使用 Handlebars 模板的 HTML 或 PDF。 + + +# 同步 + +同步是 StackEdit中文版 的最大特点之一。它使您可以将文档空间中的任何文件与存储在**Gitee** 和 **GitHub** 账号中的其他文件同步。这使您可以继续在其他设备上写作,与您共享文件的人协作,轻松集成到您的工作流程中......同步机制在后台每分钟触发一次,下载、合并和上传文件修改。 + +有两种类型的同步,它们可以相互补充: + +- 文档空间同步将自动同步您的所有文件、文件夹和设置。这将允许您在任何其他设备上获取您的文档空间。 +> 要开始同步您的文档空间,只需在菜单中使用 Gitee 登录。 + +- 文件同步将保持文档空间的一个文件与**Gitee**或**GitHub**中的一个或多个文件同步。 +> 在开始同步文件之前,您必须在**同步**子菜单中链接一个账号。 + +## 打开一个文件 + +您可以通过打开 **同步** 子菜单并单击 **从...打开** 从**Gitee** 或 **GitHub** 打开文件。在文档空间中打开后,文件中的任何修改都将自动同步。 + +## 保存文件 + +您可以通过打开 **同步** 子菜单并单击 **在...保存** 将文档空间的任何文件保存到**Gitee** 或 **GitHub**。即使文档空间中的文件已经同步,您也可以将其保存到另一个位置。 StackEdit中文版 可以将一个文件与多个位置和账号同步。 + +## 同步文件 + +一旦您的文件链接到同步位置,StackEdit中文版 将通过下载/上传任何修改来定期同步它。如有必要,将执行合并并解决冲突。 + +如果您刚刚修改了文件并且想要强制同步,请单击导航栏中的 **立即同步** 按钮。 + +> **注意:** 如果您没有要同步的文件,**立即同步**按钮将被禁用。 + +## 管理文件同步 + +由于一个文件可以与多个位置同步,您可以通过单击**同步**子菜单中的**文件同步**列出和管理同步位置。这允许您列出和删除链接到您的文件的同步位置。 + + +# 发布 + +在 StackEdit中文版 中发布使您可以轻松地在线发布文件。对文件感到满意后,您可以将其发布到不同的托管平台,例如 **Blogger**、**Gitee**、**Gist**、**GitHub**、**WordPress** 和 **Zendesk**。使用 [Handlebars 模板](http://handlebarsjs.com/),您可以完全控制导出的内容。 + +> 在开始发布之前,您必须在**发布**子菜单中链接一个账号。 + +## 发布文件 + +您可以通过打开 **发布** 子菜单并单击 **发布到** 来发布您的文件。对于某些位置,您可以选择以下格式: + +- Markdown:在可以解释的网站上发布 Markdown 文本(例如**GitHub**), +- HTML:通过 Handlebars 模板发布转换为 HTML 的文件(例如在博客上)。 + +## 更新发布 + +发布后,StackEdit中文版 会将您的文件链接到该发布,这使您可以轻松地重新发布它。一旦您修改了文件并想要更新您的发布,请单击导航栏中的**立即发布**按钮。 + +> **注意:** 如果您没有要同步的文件,**立即同步**按钮将被禁用。 + +## 管理文件同步 + +由于一个文件可以与多个位置同步,您可以通过单击**同步**子菜单中的**文件同步**列出和管理同步位置。这允许您列出和删除链接到您的文件的同步位置。 + +# Markdown扩展 + +StackEdit中文版 通过添加额外的 **Markdown扩展** 扩展了标准 Markdown 语法,为您提供了一些不错的功能。 + +> **提示:** 您可以在 **文件属性** 对话框中禁用任何 **Markdown 扩展名**。 + + +## SmartyPants + +SmartyPants 将 ASCII 标点字符转换为“智能”印刷标点 HTML 实体。例如: + +| |ASCII |HTML | +|----------------|--------------------------------| ------------------------------| +|单反引号|`'这不好玩吗?'` |'这不好玩吗?' | +|引用|`“这不好玩吗?”` |“这不好玩吗?” | +|破折号 |`-- 是破折号,--- 是破折号`|-- 是破折号,--- 是破折号| + + +## KaTeX + +您可以使用 [KaTeX](https://khan.github.io/KaTeX/) 渲染 LaTeX 数学表达式: + +满足 $\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$ 的 *Gamma 函数* 是通过欧拉积分 + +$$ +\Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. +$$ + +> 您可以在 [这里](http://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference) 找到有关 **LaTeX** 数学表达式的更多信息。 + + +## UML 图 + +您可以使用 [Mermaid](https://mermaidjs.github.io/) 渲染 UML 图。例如,这将产生一个序列图: + +```mermaid +sequenceDiagram +爱丽丝 ->> 鲍勃: 你好鲍勃,你好吗? +鲍勃-->>约翰: 约翰,你呢? +鲍勃--x 爱丽丝: 我很好,谢谢! +鲍勃-x 约翰: 我很好,谢谢! +Note right of 约翰: 鲍勃想了很长+ {{{files.0.content.html}}} ++
很长的时间,太长了
文本确实
不能放在一行中。 + +鲍勃-->爱丽丝: 正在和 John 核对... +爱丽丝->约翰: 是的……约翰,你好吗? +``` + +这将产生一个流程图: + +```mermaid +graph LR +A[长方形] -- 链接文本 --> B((圆形)) +A --> C(圆角矩形) +B --> D{菱形} +C --> D +``` diff --git a/src/extensions/abcExtension.js b/src/extensions/abcExtension.js new file mode 100644 index 0000000..850773a --- /dev/null +++ b/src/extensions/abcExtension.js @@ -0,0 +1,21 @@ +import renderAbc from 'abcjs/src/api/abc_tunebook_svg'; +import extensionSvc from '../services/extensionSvc'; + +const render = (elt) => { + const content = elt.textContent; + // Create a div element + const divElt = document.createElement('div'); + divElt.className = 'abc-notation-block'; + // Replace the pre element with the div + elt.parentNode.parentNode.replaceChild(divElt, elt.parentNode); + renderAbc(divElt, content, {}); +}; + +extensionSvc.onGetOptions((options, properties) => { + options.abc = properties.extensions.abc.enabled; +}); + +extensionSvc.onSectionPreview((elt) => { + elt.querySelectorAll('.prism.language-abc') + .cl_each(notationElt => render(notationElt)); +}); diff --git a/src/extensions/emojiExtension.js b/src/extensions/emojiExtension.js new file mode 100644 index 0000000..7146ced --- /dev/null +++ b/src/extensions/emojiExtension.js @@ -0,0 +1,13 @@ +import markdownItEmoji from 'markdown-it-emoji'; +import extensionSvc from '../services/extensionSvc'; + +extensionSvc.onGetOptions((options, properties) => { + options.emoji = properties.extensions.emoji.enabled; + options.emojiShortcuts = properties.extensions.emoji.shortcuts; +}); + +extensionSvc.onInitConverter(1, (markdown, options) => { + if (options.emoji) { + markdown.use(markdownItEmoji, options.emojiShortcuts ? {} : { shortcuts: {} }); + } +}); diff --git a/src/extensions/index.js b/src/extensions/index.js new file mode 100644 index 0000000..347b396 --- /dev/null +++ b/src/extensions/index.js @@ -0,0 +1,5 @@ +import './emojiExtension'; +import './abcExtension'; +import './katexExtension'; +import './markdownExtension'; +import './mermaidExtension'; diff --git a/src/extensions/katexExtension.js b/src/extensions/katexExtension.js new file mode 100644 index 0000000..4eb3d19 --- /dev/null +++ b/src/extensions/katexExtension.js @@ -0,0 +1,32 @@ +import katex from 'katex'; +import markdownItMath from './libs/markdownItMath'; +import extensionSvc from '../services/extensionSvc'; + +extensionSvc.onGetOptions((options, properties) => { + options.math = properties.extensions.katex.enabled; +}); + +extensionSvc.onInitConverter(2, (markdown, options) => { + if (options.math) { + markdown.use(markdownItMath); + markdown.renderer.rules.inline_math = (tokens, idx) => + `${markdown.utils.escapeHtml(tokens[idx].content)}`; + markdown.renderer.rules.display_math = (tokens, idx) => + `${markdown.utils.escapeHtml(tokens[idx].content)}`; + } +}); + +extensionSvc.onSectionPreview((elt) => { + const highlighter = displayMode => (katexElt) => { + if (!katexElt.highlighted) { + try { + katex.render(katexElt.textContent, katexElt, { displayMode }); + } catch (e) { + katexElt.textContent = `${e.message}`; + } + } + katexElt.highlighted = true; + }; + elt.querySelectorAll('.katex--inline').cl_each(highlighter(false)); + elt.querySelectorAll('.katex--display').cl_each(highlighter(true)); +}); diff --git a/src/extensions/libs/markdownItAnchor.js b/src/extensions/libs/markdownItAnchor.js new file mode 100644 index 0000000..8281c4a --- /dev/null +++ b/src/extensions/libs/markdownItAnchor.js @@ -0,0 +1,133 @@ +function groupHeadings(headings, level = 1) { + const result = []; + let currentItem; + + function pushCurrentItem() { + if (currentItem) { + if (currentItem.children.length > 0) { + currentItem.children = groupHeadings(currentItem.children, level + 1); + } + result.push(currentItem); + } + } + headings.forEach((heading) => { + if (heading.level !== level) { + currentItem = currentItem || { + children: [], + }; + currentItem.children.push(heading); + } else { + pushCurrentItem(); + currentItem = heading; + } + }); + pushCurrentItem(); + return result; +} + +function arrayToHtml(arr) { + if (!arr || !arr.length) { + return ''; + } + const ulHtml = arr.map((item) => { + let result = `
Insert Hyperlink
http://example.com/ \"optional title\"
", + + quote: "BlockquoteCtrl/Cmd+Q", + quoteexample: "块引用", + + code: "Code SampleCtrl/Cmd+K", + codeexample: "这里输入代码", + + image: "Image
Ctrl/Cmd+G", + imagedescription: "输入图片说明", + imagedialog: "
Insert Image
http://example.com/images/diagram.jpg \"optional title\"
", + + olist: "Numbered List
Need free image hosting?Ctrl/Cmd+O", + ulist: "Bulleted List
Ctrl/Cmd+U", + litem: "这里是列表文本", + + heading: "Heading
/
Ctrl/Cmd+H", + headingexample: "标题", + + hr: "Horizontal Rule
Ctrl/Cmd+R", + + undo: "Undo - Ctrl/Cmd+Z", + redo: "Redo - Ctrl/Cmd+Y", + + help: "Markdown Editing Help", + + formulaexample: "这里输入Latex表达式", +}; + +// options, if given, can have the following properties: +// options.helpButton = { handler: yourEventHandler } +// options.strings = { italicexample: "slanted text" } +// `yourEventHandler` is the click handler for the help button. +// If `options.helpButton` isn't given, not help button is created. +// `options.strings` can have any or all of the same properties as +// `defaultStrings` above, so you can just override some string displayed +// to the user on a case-by-case basis, or translate all strings to +// a different language. +// +// For backwards compatibility reasons, the `options` argument can also +// be just the `helpButton` object, and `strings.help` can also be set via +// `helpButton.title`. This should be considered legacy. +// +// The constructed editor object has the methods: +// - getConverter() returns the markdown converter object that was passed to the constructor +// - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op. +// - refreshPreview() forces the preview to be updated. This method is only available after run() was called. +function Pagedown(options) { + + options = options || {}; + + if (typeof options.handler === "function") { //backwards compatible behavior + options = { + helpButton: options + }; + } + options.strings = options.strings || {}; + var getString = function (identifier) { + return options.strings[identifier] || defaultsStrings[identifier]; + }; + + function identity(x) { + return x; + } + + function returnFalse() { + return false; + } + + function HookCollection() { } + HookCollection.prototype = { + + chain: function (hookname, func) { + var original = this[hookname]; + if (!original) { + throw new Error("unknown hook " + hookname); + } + + if (original === identity) { + this[hookname] = func; + } else { + this[hookname] = function () { + var args = Array.prototype.slice.call(arguments, 0); + args[0] = original.apply(null, args); + return func.apply(null, args); + }; + } + }, + set: function (hookname, func) { + if (!this[hookname]) { + throw new Error("unknown hook " + hookname); + } + this[hookname] = func; + }, + addNoop: function (hookname) { + this[hookname] = identity; + }, + addFalse: function (hookname) { + this[hookname] = returnFalse; + } + }; + + var hooks = this.hooks = new HookCollection(); + hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed + hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text + hooks.addFalse("insertImageDialog"); + hooks.addFalse("insertChatGptDialog"); + /* called with one parameter: a callback to be called with the URL of the image. If the application creates + * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen + * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. + */ + hooks.addFalse("insertLinkDialog"); + // 插入图片占位字符 + hooks.addFalse("insertImageUploading"); + + var that = this, + input; + + this.run = function () { + if (input) + return; // already initialized + + input = options.input; + var commandManager = new CommandManager(hooks, getString); + var uiManager; + + uiManager = new UIManager(input, commandManager); + + that.uiManager = uiManager; + }; + +} + +// before: contains all the text in the input box BEFORE the selection. +// after: contains all the text in the input box AFTER the selection. +function Chunks() { } + +// startRegex: a regular expression to find the start tag +// endRegex: a regular expresssion to find the end tag +Chunks.prototype.findTags = function (startRegex, endRegex) { + + var chunkObj = this; + var regex; + + if (startRegex) { + + regex = util.extendRegExp(startRegex, "", "$"); + + this.before = this.before.replace(regex, + function (match) { + chunkObj.startTag = chunkObj.startTag + match; + return ""; + }); + + regex = util.extendRegExp(startRegex, "^", ""); + + this.selection = this.selection.replace(regex, + function (match) { + chunkObj.startTag = chunkObj.startTag + match; + return ""; + }); + } + + if (endRegex) { + + regex = util.extendRegExp(endRegex, "", "$"); + + this.selection = this.selection.replace(regex, + function (match) { + chunkObj.endTag = match + chunkObj.endTag; + return ""; + }); + + regex = util.extendRegExp(endRegex, "^", ""); + + this.after = this.after.replace(regex, + function (match) { + chunkObj.endTag = match + chunkObj.endTag; + return ""; + }); + } +}; + +// If remove is false, the whitespace is transferred +// to the before/after regions. +// +// If remove is true, the whitespace disappears. +Chunks.prototype.trimWhitespace = function (remove) { + var beforeReplacer, afterReplacer, that = this; + if (remove) { + beforeReplacer = afterReplacer = ""; + } else { + beforeReplacer = function (s) { + that.before += s; + return ""; + }; + afterReplacer = function (s) { + that.after = s + that.after; + return ""; + }; + } + + this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); +}; + + +Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) { + + if (nLinesBefore === undefined) { + nLinesBefore = 1; + } + + if (nLinesAfter === undefined) { + nLinesAfter = 1; + } + + nLinesBefore++; + nLinesAfter++; + + var regexText; + var replacementText; + + // chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985 + if (navigator.userAgent.match(/Chrome/)) { + "X".match(/()./); + } + + this.selection = this.selection.replace(/(^\n*)/, ""); + + this.startTag = this.startTag + re.$1; + + this.selection = this.selection.replace(/(\n*$)/, ""); + this.endTag = this.endTag + re.$1; + this.startTag = this.startTag.replace(/(^\n*)/, ""); + this.before = this.before + re.$1; + this.endTag = this.endTag.replace(/(\n*$)/, ""); + this.after = this.after + re.$1; + + if (this.before) { + + regexText = replacementText = ""; + + while (nLinesBefore--) { + regexText += "\\n?"; + replacementText += "\n"; + } + + if (findExtraNewlines) { + regexText = "\\n*"; + } + this.before = this.before.replace(new re(regexText + "$", ""), replacementText); + } + + if (this.after) { + + regexText = replacementText = ""; + + while (nLinesAfter--) { + regexText += "\\n?"; + replacementText += "\n"; + } + if (findExtraNewlines) { + regexText = "\\n*"; + } + + this.after = this.after.replace(new re(regexText, ""), replacementText); + } +}; + +// end of Chunks + +// Converts \r\n and \r to \n. +util.fixEolChars = function (text) { + text = text.replace(/\r\n/g, "\n"); + text = text.replace(/\r/g, "\n"); + return text; +}; + +// Extends a regular expression. Returns a new RegExp +// using pre + regex + post as the expression. +// Used in a few functions where we have a base +// expression and we want to pre- or append some +// conditions to it (e.g. adding "$" to the end). +// The flags are unchanged. +// +// regex is a RegExp, pre and post are strings. +util.extendRegExp = function (regex, pre, post) { + + if (pre === null || pre === undefined) { + pre = ""; + } + if (post === null || post === undefined) { + post = ""; + } + + var pattern = regex.toString(); + var flags; + + // Replace the flags with empty space and store them. + pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) { + flags = flagsPart; + return ""; + }); + + // Remove the slash delimiters on the regular expression. + pattern = pattern.replace(/(^\/|\/$)/g, ""); + pattern = pre + pattern + post; + + return new re(pattern, flags); +}; + +// The input textarea state/contents. +// This is used to implement undo/redo by the undo manager. +function TextareaState(input) { + + // Aliases + var stateObj = this; + var inputArea = input; + this.init = function () { + this.setInputAreaSelectionStartEnd(); + this.text = inputArea.getContent(); + }; + + // Sets the selected text in the input box after we've performed an + // operation. + this.setInputAreaSelection = function () { + inputArea.focus(); + inputArea.setSelection(stateObj.start, stateObj.end); + }; + + this.setInputAreaSelectionStartEnd = function () { + stateObj.start = Math.min( + inputArea.selectionMgr.selectionStart, + inputArea.selectionMgr.selectionEnd + ); + stateObj.end = Math.max( + inputArea.selectionMgr.selectionStart, + inputArea.selectionMgr.selectionEnd + ); + }; + + // Restore this state into the input area. + this.restore = function () { + + if (stateObj.text !== undefined && stateObj.text != inputArea.getContent()) { + inputArea.setContent(stateObj.text); + } + this.setInputAreaSelection(); + }; + + // Gets a collection of HTML chunks from the inptut textarea. + this.getChunks = function () { + + var chunk = new Chunks(); + chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start)); + chunk.startTag = ""; + chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end)); + chunk.endTag = ""; + chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end)); + + return chunk; + }; + + // Sets the TextareaState properties given a chunk of markdown. + this.setChunks = function (chunk) { + + chunk.before = chunk.before + chunk.startTag; + chunk.after = chunk.endTag + chunk.after; + + this.start = chunk.before.length; + this.end = chunk.before.length + chunk.selection.length; + this.text = chunk.before + chunk.selection + chunk.after; + }; + this.init(); +} + +function UIManager(input, commandManager) { + + var inputBox = input, + buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements. + + makeSpritedButtonRow(); + + // Perform the button's action. + function doClick(buttonName) { + var button = buttons[buttonName]; + if (!button) { + return; + } + + inputBox.focus(); + var linkOrImage = button === buttons.link || button.id === buttons.image; + + var state = new TextareaState(input); + + if (!state) { + return; + } + + var chunks = state.getChunks(); + + // Some commands launch a "modal" prompt dialog. Javascript + // can't really make a modal dialog box and the WMD code + // will continue to execute while the dialog is displayed. + // This prevents the dialog pattern I'm used to and means + // I can't do something like this: + // + // var link = CreateLinkDialog(); + // makeMarkdownLink(link); + // + // Instead of this straightforward method of handling a + // dialog I have to pass any code which would execute + // after the dialog is dismissed (e.g. link creation) + // in a function parameter. + // + // Yes this is awkward and I think it sucks, but there's + // no real workaround. Only the image and link code + // create dialogs and require the function pointers. + var fixupInputArea = function () { + + inputBox.focus(); + + if (chunks) { + state.setChunks(chunks); + } + + state.restore(); + }; + + var noCleanup = button(chunks, fixupInputArea); + + if (!noCleanup) { + fixupInputArea(); + if (!linkOrImage) { + inputBox.adjustCursorPosition(); + } + } + } + + function bindCommand(method) { + if (typeof method === "string") + method = commandManager[method]; + return function () { + method.apply(commandManager, arguments); + }; + } + + function makeSpritedButtonRow() { + + buttons.bold = bindCommand("doBold"); + buttons.italic = bindCommand("doItalic"); + buttons.strikethrough = bindCommand("doStrikethrough"); + buttons.inlineformula = bindCommand("doInlinkeFormula"); + buttons.imageUploading = bindCommand("doImageUploading"); + buttons.link = bindCommand(function (chunk, postProcessing) { + return this.doLinkOrImage(chunk, postProcessing, false); + }); + buttons.quote = bindCommand("doBlockquote"); + buttons.code = bindCommand("doCode"); + buttons.image = bindCommand(function (chunk, postProcessing) { + return this.doLinkOrImage(chunk, postProcessing, true); + }); + buttons.chatgpt = bindCommand("doChatGpt"); + buttons.olist = bindCommand(function (chunk, postProcessing) { + this.doList(chunk, postProcessing, true); + }); + buttons.ulist = bindCommand(function (chunk, postProcessing) { + this.doList(chunk, postProcessing, false); + }); + buttons.clist = bindCommand(function (chunk, postProcessing) { + this.doList(chunk, postProcessing, false, true); + }); + buttons.heading = bindCommand("doHeading"); + buttons.hr = bindCommand("doHorizontalRule"); + buttons.table = bindCommand("doTable"); + } + + this.doClick = doClick; + +} + +function CommandManager(pluginHooks, getString) { + this.hooks = pluginHooks; + this.getString = getString; +} + +var commandProto = CommandManager.prototype; + +// The markdown symbols - 4 spaces = code, > = blockquote, etc. +commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)"; + +// Remove markdown symbols from the chunk selection. +commandProto.unwrap = function (chunk) { + var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g"); + chunk.selection = chunk.selection.replace(txt, "$1 $2"); +}; + +commandProto.wrap = function (chunk, len) { + this.unwrap(chunk); + var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"), + that = this; + + chunk.selection = chunk.selection.replace(regex, function (line, marked) { + if (new re("^" + that.prefixes, "").test(line)) { + return line; + } + return marked + "\n"; + }); + + chunk.selection = chunk.selection.replace(/\s+$/, ""); +}; + +commandProto.doBold = function (chunk, postProcessing) { + return this.doBorI(chunk, postProcessing, 2, this.getString("boldexample")); +}; + +commandProto.doItalic = function (chunk, postProcessing) { + return this.doBorI(chunk, postProcessing, 1, this.getString("italicexample")); +}; + +// chunk: The selected region that will be enclosed with */** +// nStars: 1 for italics, 2 for bold +// insertText: If you just click the button without highlighting text, this gets inserted +commandProto.doBorI = function (chunk, postProcessing, nStars, insertText) { + + // Get rid of whitespace and fixup newlines. + chunk.trimWhitespace(); + chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); + + // Look for stars before and after. Is the chunk already marked up? + // note that these regex matches cannot fail + var starsBefore = /(\**$)/.exec(chunk.before)[0]; + var starsAfter = /(^\**)/.exec(chunk.after)[0]; + + var prevStars = Math.min(starsBefore.length, starsAfter.length); + + // Remove stars if we have to since the button acts as a toggle. + if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { + chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), ""); + chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), ""); + } else if (!chunk.selection && starsAfter) { + // It's not really clear why this code is necessary. It just moves + // some arbitrary stuff around. + chunk.after = chunk.after.replace(/^([*_]*)/, ""); + chunk.before = chunk.before.replace(/(\s?)$/, ""); + var whitespace = re.$1; + chunk.before = chunk.before + starsAfter + whitespace; + } else { + + // In most cases, if you don't have any selected text and click the button + // you'll get a selected, marked up region with the default text inserted. + if (!chunk.selection && !starsAfter) { + chunk.selection = insertText; + } + + // Add the true markup. + var markup = nStars <= 1 ? "*" : "**"; // shouldn't the test be = ? + chunk.before = chunk.before + markup; + chunk.after = markup + chunk.after; + } + + return; +}; + +commandProto.doStrikethrough = function (chunk, postProcessing) { + + // Get rid of whitespace and fixup newlines. + chunk.trimWhitespace(); + chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); + + // Look for stars before and after. Is the chunk already marked up? + // note that these regex matches cannot fail + var starsBefore = /(~*$)/.exec(chunk.before)[0]; + var starsAfter = /(^~*)/.exec(chunk.after)[0]; + + var prevStars = Math.min(starsBefore.length, starsAfter.length); + + var nStars = 2; + + // Remove stars if we have to since the button acts as a toggle. + if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { + chunk.before = chunk.before.replace(re("[~]{" + nStars + "}$", ""), ""); + chunk.after = chunk.after.replace(re("^[~]{" + nStars + "}", ""), ""); + } else if (!chunk.selection && starsAfter) { + // It's not really clear why this code is necessary. It just moves + // some arbitrary stuff around. + chunk.after = chunk.after.replace(/^(~*)/, ""); + chunk.before = chunk.before.replace(/(\s?)$/, ""); + var whitespace = re.$1; + chunk.before = chunk.before + starsAfter + whitespace; + } else { + + // In most cases, if you don't have any selected text and click the button + // you'll get a selected, marked up region with the default text inserted. + if (!chunk.selection && !starsAfter) { + chunk.selection = this.getString("strikethroughexample"); + } + + // Add the true markup. + var markup = "~~"; // shouldn't the test be = ? + chunk.before = chunk.before + markup; + chunk.after = markup + chunk.after; + } + + return; +}; + +commandProto.doInlinkeFormula = function (chunk, postProcessing) { + + // Get rid of whitespace and fixup newlines. + chunk.trimWhitespace(); + chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); + + // Look for stars before and after. Is the chunk already marked up? + // note that these regex matches cannot fail + var starsBefore = /(\$*$)/.exec(chunk.before)[0]; + var starsAfter = /(^\$*)/.exec(chunk.after)[0]; + + var prevStars = Math.min(starsBefore.length, starsAfter.length); + + var nStars = 2; + + // Remove stars if we have to since the button acts as a toggle. + if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { + chunk.before = chunk.before.replace(re("[\$]{" + nStars + "}$", ""), ""); + chunk.after = chunk.after.replace(re("^[\$]{" + nStars + "}", ""), ""); + } else if (!chunk.selection && starsAfter) { + // It's not really clear why this code is necessary. It just moves + // some arbitrary stuff around. + chunk.after = chunk.after.replace(/^(\$*)/, ""); + chunk.before = chunk.before.replace(/(\s?)$/, ""); + var whitespace = re.$1; + chunk.before = chunk.before + starsAfter + whitespace; + } else { + + // In most cases, if you don't have any selected text and click the button + // you'll get a selected, marked up region with the default text inserted. + if (!chunk.selection && !starsAfter) { + chunk.selection = this.getString("formulaexample"); + } + + // Add the true markup. + var markup = "$"; // shouldn't the test be = ? + chunk.before = chunk.before + markup; + chunk.after = markup + chunk.after; + } + + return; +}; + +commandProto.doImageUploading = function (chunk, postProcessing) { + var enteredCallback = function (imgId) { + if (imgId !== null) { + chunk.before = `${chunk.before}[图片上传中...(image-${imgId})]`; + chunk.selection = ''; + } + postProcessing(); + }; + this.hooks.insertImageUploading(enteredCallback); +} + +commandProto.stripLinkDefs = function (text, defsToAdd) { + + text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*(\S+?)>?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm, + function (totalMatch, id, link, newlines, title) { + defsToAdd[id] = totalMatch.replace(/\s*$/, ""); + if (newlines) { + // Strip the title and return that separately. + defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, ""); + return newlines + title; + } + return ""; + }); + + return text; +}; + +commandProto.addLinkDef = function (chunk, linkDef) { + + var refNumber = 0; // The current reference number + var defsToAdd = {}; // + // Start with a clean slate by removing all previous link definitions. + chunk.before = this.stripLinkDefs(chunk.before, defsToAdd); + chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd); + chunk.after = this.stripLinkDefs(chunk.after, defsToAdd); + + var defs = ""; + var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g; + + var addDefNumber = function (def) { + refNumber++; + def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:"); + defs += "\n" + def; + }; + + // note that + // a) the recursive call to getLink cannot go infinite, because by definition + // of regex, inner is always a proper substring of wholeMatch, and + // b) more than one level of nesting is neither supported by the regex + // nor making a lot of sense (the only use case for nesting is a linked image) + var getLink = function (wholeMatch, before, inner, afterInner, id, end) { + inner = inner.replace(regex, getLink); + if (defsToAdd[id]) { + addDefNumber(defsToAdd[id]); + return before + inner + afterInner + refNumber + end; + } + return wholeMatch; + }; + + chunk.before = chunk.before.replace(regex, getLink); + + if (linkDef) { + addDefNumber(linkDef); + } else { + chunk.selection = chunk.selection.replace(regex, getLink); + } + + var refOut = refNumber; + + chunk.after = chunk.after.replace(regex, getLink); + + if (chunk.after) { + chunk.after = chunk.after.replace(/\n*$/, ""); + } + if (!chunk.after) { + chunk.selection = chunk.selection.replace(/\n*$/, ""); + } + + chunk.after += "\n\n" + defs; + + return refOut; +}; + +// takes the line as entered into the add link/as image dialog and makes +// sure the URL and the optinal title are "nice". +function properlyEncoded(linkdef) { + return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) { + link = link.replace(/\?.*$/, function (querypart) { + return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical + }); + link = decodeURIComponent(link); // unencode first, to prevent double encoding + link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); + link = link.replace(/\?.*$/, function (querypart) { + return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded + }); + if (title) { + title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, ""); + title = title.replace(/"/g, "quot;").replace(/\(/g, "(").replace(/\)/g, ")").replace(//g, ">"); + } + return title ? link + ' "' + title + '"' : link; + }); +} + +commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) { + + chunk.trimWhitespace(); + //chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); + chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\(.*?\))?/); + + if (chunk.endTag.length > 1 && chunk.startTag.length > 0) { + + chunk.startTag = chunk.startTag.replace(/!?\[/, ""); + chunk.endTag = ""; + this.addLinkDef(chunk, null); + + } else { + + // We're moving start and end tag back into the selection, since (as we're in the else block) we're not + // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the + // link text. linkEnteredCallback takes care of escaping any brackets. + chunk.selection = chunk.startTag + chunk.selection + chunk.endTag; + chunk.startTag = chunk.endTag = ""; + + if (/\n\n/.test(chunk.selection)) { + this.addLinkDef(chunk, null); + return; + } + var that = this; + // The function to be executed when you enter a link and press OK or Cancel. + // Marks up the link and adds the ref. + var linkEnteredCallback = function (link) { + + if (link !== null) { + // ( $1 + // [^\\] anything that's not a backslash + // (?:\\\\)* an even number (this includes zero) of backslashes + // ) + // (?= followed by + // [[\]] an opening or closing bracket + // ) + // + // In other words, a non-escaped bracket. These have to be escaped now to make sure they + // don't count as the end of the link or similar. + // Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets), + // the bracket in one match may be the "not a backslash" character in the next match, so it + // should not be consumed by the first match. + // The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the + // start of the string, so this also works if the selection begins with a bracket. We cannot solve + // this by anchoring with ^, because in the case that the selection starts with two brackets, this + // would mean a zero-width match at the start. Since zero-width matches advance the string position, + // the first bracket could then not act as the "not a backslash" for the second. + chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); + + /* + var linkDef = " [999]: " + properlyEncoded(link); + + var num = that.addLinkDef(chunk, linkDef); + */ + chunk.startTag = isImage ? "![" : "["; + //chunk.endTag = "][" + num + "]"; + chunk.endTag = "](" + properlyEncoded(link) + ")"; + + if (!chunk.selection) { + if (isImage) { + chunk.selection = that.getString("imagedescription"); + } else { + chunk.selection = that.getString("linkdescription"); + } + } + } + postProcessing(); + }; + + if (isImage) { + this.hooks.insertImageDialog(linkEnteredCallback); + } else { + this.hooks.insertLinkDialog(linkEnteredCallback); + } + return true; + } +}; + +commandProto.doChatGpt = function (chunk, postProcessing) { + var enteredCallback = function (content) { + if (content !== null) { + chunk.before = `${chunk.before}${content}`; + chunk.selection = ''; + } + postProcessing(); + }; + this.hooks.insertChatGptDialog(enteredCallback); +}; + +// When making a list, hitting shift-enter will put your cursor on the next line +// at the current indent level. +commandProto.doAutoindent = function (chunk) { + + var commandMgr = this, + fakeSelection = false; + + chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]*\n$/, "\n\n"); + chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}>[ \t]*\n$/, "\n\n"); + chunk.before = chunk.before.replace(/(\n|^)[ \t]+\n$/, "\n\n"); + + // There's no selection, end the cursor wasn't at the end of the line: + // The user wants to split the current list item / code line / blockquote line + // (for the latter it doesn't really matter) in two. Temporarily select the + // (rest of the) line to achieve this. + if (!chunk.selection && !/^[ \t]*(?:\n|$)/.test(chunk.after)) { + chunk.after = chunk.after.replace(/^[^\n]*/, function (wholeMatch) { + chunk.selection = wholeMatch; + return ""; + }); + fakeSelection = true; + } + + if (/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]+.*\n$/.test(chunk.before)) { + if (commandMgr.doList) { + commandMgr.doList(chunk); + } + } + if (/(\n|^)[ ]{0,3}>[ \t]+.*\n$/.test(chunk.before)) { + if (commandMgr.doBlockquote) { + commandMgr.doBlockquote(chunk); + } + } + if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { + if (commandMgr.doCode) { + commandMgr.doCode(chunk); + } + } + + if (fakeSelection) { + chunk.after = chunk.selection + chunk.after; + chunk.selection = ""; + } +}; + +commandProto.doBlockquote = function (chunk) { + + chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/, + function (totalMatch, newlinesBefore, text, newlinesAfter) { + chunk.before += newlinesBefore; + chunk.after = newlinesAfter + chunk.after; + return text; + }); + + chunk.before = chunk.before.replace(/(>[ \t]*)$/, + function (totalMatch, blankLine) { + chunk.selection = blankLine + chunk.selection; + return ""; + }); + + chunk.selection = chunk.selection.replace(/^(\s|>)+$/, ""); + chunk.selection = chunk.selection || this.getString("quoteexample"); + + // The original code uses a regular expression to find out how much of the + // text *directly before* the selection already was a blockquote: + + /* + if (chunk.before) { + chunk.before = chunk.before.replace(/\n?$/, "\n"); + } + chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/, + function (totalMatch) { + chunk.startTag = totalMatch; + return ""; + }); + */ + + // This comes down to: + // Go backwards as many lines a possible, such that each line + // a) starts with ">", or + // b) is almost empty, except for whitespace, or + // c) is preceeded by an unbroken chain of non-empty lines + // leading up to a line that starts with ">" and at least one more character + // and in addition + // d) at least one line fulfills a) + // + // Since this is essentially a backwards-moving regex, it's susceptible to + // catstrophic backtracking and can cause the browser to hang; + // see e.g. http://meta.stackoverflow.com/questions/9807. + // + // Hence we replaced this by a simple state machine that just goes through the + // lines and checks for a), b), and c). + + var match = "", + leftOver = "", + line; + if (chunk.before) { + var lines = chunk.before.replace(/\n$/, "").split("\n"); + var inChain = false; + for (var i = 0; i < lines.length; i++) { + var good = false; + line = lines[i]; + inChain = inChain && line.length > 0; // c) any non-empty line continues the chain + if (/^>/.test(line)) { // a) + good = true; + if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain + inChain = true; + } else if (/^[ \t]*$/.test(line)) { // b) + good = true; + } else { + good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain + } + if (good) { + match += line + "\n"; + } else { + leftOver += match + line; + match = "\n"; + } + } + if (!/(^|\n)>/.test(match)) { // d) + leftOver += match; + match = ""; + } + } + + chunk.startTag = match; + chunk.before = leftOver; + + // end of change + + if (chunk.after) { + chunk.after = chunk.after.replace(/^\n?/, "\n"); + } + + chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/, + function (totalMatch) { + chunk.endTag = totalMatch; + return ""; + } + ); + + var replaceBlanksInTags = function (useBracket) { + + var replacement = useBracket ? "> " : ""; + + if (chunk.startTag) { + chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/, + function (totalMatch, markdown) { + return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; + }); + } + if (chunk.endTag) { + chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/, + function (totalMatch, markdown) { + return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; + }); + } + }; + + if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) { + this.wrap(chunk, SETTINGS.lineLength - 2); + chunk.selection = chunk.selection.replace(/^/gm, "> "); + replaceBlanksInTags(true); + chunk.skipLines(); + } else { + chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, ""); + this.unwrap(chunk); + replaceBlanksInTags(false); + + if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) { + chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n"); + } + + if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) { + chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n"); + } + } + + chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection); + + if (!/\n/.test(chunk.selection)) { + chunk.selection = chunk.selection.replace(/^(> *)/, + function (wholeMatch, blanks) { + chunk.startTag += blanks; + return ""; + }); + } +}; + +commandProto.doCode = function (chunk) { + + var hasTextBefore = /\S[ ]*$/.test(chunk.before); + var hasTextAfter = /^[ ]*\S/.test(chunk.after); + + // Use 'four space' markdown if the selection is on its own + // line or is multiline. + if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) { + if (/[\n]+```\n$/.test(chunk.before) && /^\n```[ ]*\n/.test(chunk.after)) { + chunk.before = chunk.before.replace(/```\n$/, ""); + chunk.after = chunk.after.replace(/^\n```/, ""); + } else { + chunk.before += '```\n'; + chunk.after = '\n```' + chunk.after; + } + if (!chunk.selection) { + chunk.selection = this.getString("codeexample"); + } + } else { + // Use backticks (`) to delimit the code block. + + chunk.trimWhitespace(); + chunk.findTags(/`/, /`/); + + if (!chunk.startTag && !chunk.endTag) { + chunk.startTag = chunk.endTag = "`"; + if (!chunk.selection) { + chunk.selection = this.getString("codeexample"); + } + } else if (chunk.endTag && !chunk.startTag) { + chunk.before += chunk.endTag; + chunk.endTag = ""; + } else { + chunk.startTag = chunk.endTag = ""; + } + } +}; + +commandProto.doList = function (chunk, postProcessing, isNumberedList, isCheckList) { + + // These are identical except at the very beginning and end. + // Should probably use the regex extension function to make this clearer. + var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/; + var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/; + + // The default bullet is a dash but others are possible. + // This has nothing to do with the particular HTML bullet, + // it's just a markdown bullet. + var bullet = "-"; + + // The number in a numbered list. + var num = 1; + + // Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list. + var getItemPrefix = function (checkListContent) { + var prefix; + if (isNumberedList) { + prefix = " " + num + ". "; + num++; + } else { + prefix = " " + bullet + " "; + if (isCheckList) { + prefix += '['; + prefix += checkListContent || ' '; + prefix += '] '; + } + } + return prefix; + }; + + // Fixes the prefixes of the other list items. + var getPrefixedItem = function (itemText) { + + // The numbering flag is unset when called by autoindent. + if (isNumberedList === undefined) { + isNumberedList = /^\s*\d/.test(itemText); + } + + // Renumber/bullet the list element. + itemText = itemText.replace(isCheckList + ? /^[ ]{0,3}([*+-]|\d+[.])\s+\[([ xX])\]\s/gm + : /^[ ]{0,3}([*+-]|\d+[.])\s/gm, + function (match, p1, p2) { + return getItemPrefix(p2); + }); + + return itemText; + }; + + chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null); + + if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) { + chunk.before += chunk.startTag; + chunk.startTag = ""; + } + + if (chunk.startTag) { + + var hasDigits = /\d+[.]/.test(chunk.startTag); + chunk.startTag = ""; + chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n"); + this.unwrap(chunk); + chunk.skipLines(); + + if (hasDigits) { + // Have to renumber the bullet points if this is a numbered list. + chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem); + } + if (isNumberedList == hasDigits) { + return; + } + } + + var nLinesUp = 1; + + chunk.before = chunk.before.replace(previousItemsRegex, + function (itemText) { + if (/^\s*([*+-])/.test(itemText)) { + bullet = re.$1; + } + nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; + return getPrefixedItem(itemText); + }); + + if (!chunk.selection) { + chunk.selection = this.getString("litem"); + } + + var prefix = getItemPrefix(); + + var nLinesDown = 1; + + chunk.after = chunk.after.replace(nextItemsRegex, + function (itemText) { + nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; + return getPrefixedItem(itemText); + }); + + chunk.trimWhitespace(true); + chunk.skipLines(nLinesUp, nLinesDown, true); + chunk.startTag = prefix; + var spaces = prefix.replace(/./g, " "); + this.wrap(chunk, SETTINGS.lineLength - spaces.length); + chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces); + +}; + +commandProto.doTable = function (chunk) { + // Credit: https://github.com/fcrespo82/atom-markdown-table-formatter + + var keepFirstAndLastPipes = true, + /* + ( # header capture + (?: + (?:[^\n]*?\|[^\n]*) # line w/ at least one pipe + \ * # maybe trailing whitespace + )? # maybe header + (?:\n|^) # newline + ) + ( # format capture + (?: + \|\ *:?-+:?\ * # format starting w/pipe + |\|?(?:\ *:?-+:?\ *\|)+ # or separated by pipe + ) + (?:\ *:?-+:?\ *)? # maybe w/o trailing pipe + \ * # maybe trailing whitespace + \n # newline + ) + ( # body capture + (?: + (?:[^\n]*?\|[^\n]*) # line w/ at least one pipe + \ * # maybe trailing whitespace + (?:\n|$) # newline + )+ # at least one + ) + */ + regex = /((?:(?:[^\n]*?\|[^\n]*) *)?(?:\r?\n|^))((?:\| *:?-+:? *|\|?(?: *:?-+:? *\|)+)(?: *:?-+:? *)? *\r?\n)((?:(?:[^\n]*?\|[^\n]*) *(?:\r?\n|$))+)/; + + + function padding(len, str) { + var result = ''; + str = str || ' '; + len = Math.floor(len); + for (var i = 0; i < len; i++) { + result += str; + } + return result; + } + + function stripTailPipes(str) { + return str.trim().replace(/(^\||\|$)/g, ""); + } + + function splitCells(str) { + return str.split('|'); + } + + function addTailPipes(str) { + if (keepFirstAndLastPipes) { + return "|" + str + "|"; + } else { + return str; + } + } + + function joinCells(arr) { + return arr.join('|'); + } + + function formatTable(text, appendNewline) { + var i, j, len1, ref1, ref2, ref3, k, len2, results, formatline, headerline, just, formatrow, data, line, lines, justify, cell, cells, first, last, ends, columns, content, widths, formatted, front, back; + formatline = text[2].trim(); + headerline = text[1].trim(); + ref1 = headerline.length === 0 ? [0, text[3]] : [1, text[1] + text[3]], formatrow = ref1[0], data = ref1[1]; + lines = data.trim().split('\n'); + justify = []; + ref2 = splitCells(stripTailPipes(formatline)); + for (j = 0, len1 = ref2.length; j < len1; j++) { + cell = ref2[j]; + ref3 = cell.trim(), first = ref3[0], last = ref3[ref3.length - 1]; + switch ((ends = (first ? first : ':') + (last ? last : ''))) { + case '::': + case '-:': + case ':-': + justify.push(ends); + break; + default: + justify.push('--'); + } + } + columns = justify.length; + content = []; + for (j = 0, len1 = lines.length; j < len1; j++) { + line = lines[j]; + cells = splitCells(stripTailPipes(line)); + cells[columns - 1] = joinCells(cells.slice(columns - 1)); + results = []; + for (k = 0, len2 = cells.length; k < len2; k++) { + cell = cells[k]; + results.push(padding(' ') + ((ref2 = cell ? typeof cell.trim === "function" ? cell.trim() : void 0 : void 0) ? ref2 : '') + padding(' ')); + } + content.push(results); + } + widths = []; + for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) { + results = []; + for (k = 0, len1 = content.length; k < len1; k++) { + cells = content[k]; + results.push(cells[i].length); + } + widths.push(Math.max.apply(Math, [2].concat(results))); + } + just = function (string, col) { + var back, front, length; + length = widths[col] - string.length; + switch (justify[col]) { + case '::': + front = padding[0], back = padding[1]; + return padding(length / 2) + string + padding((length + 1) / 2); + case '-:': + return padding(length) + string; + default: + return string + padding(length); + } + }; + formatted = []; + for (j = 0, len1 = content.length; j < len1; j++) { + cells = content[j]; + results = []; + for (i = k = 0, ref2 = columns - 1; 0 <= ref2 ? k <= ref2 : k >= ref2; i = 0 <= ref2 ? ++k : --k) { + results.push(just(cells[i], i)); + } + formatted.push(addTailPipes(joinCells(results))); + } + formatline = addTailPipes(joinCells((function () { + var j, ref2, ref3, results; + results = []; + for (i = j = 0, ref2 = columns - 1; 0 <= ref2 ? j <= ref2 : j >= ref2; i = 0 <= ref2 ? ++j : --j) { + ref3 = justify[i], front = ref3[0], back = ref3[1]; + results.push(front + padding(widths[i] - 2, '-') + back); + } + return results; + })())); + formatted.splice(formatrow, 0, formatline); + var result = (headerline.length === 0 && text[1] !== '' ? '\n' : '') + formatted.join('\n'); + if (appendNewline !== false) { + result += '\n' + } + return result; + } + + if (chunk.before.slice(-1) !== '\n') { + chunk.before += '\n'; + } + var match = chunk.selection.match(regex); + if (match) { + chunk.selection = formatTable(match, chunk.selection.slice(-1) === '\n'); + } else { + var table = chunk.selection + '|\n-|-\n|'; + match = table.match(regex); + if (!match || match[0].slice(0, table.length) !== table) { + return; + } + table = formatTable(match); + var selectionOffset = keepFirstAndLastPipes ? 1 : 0; + var pipePos = table.indexOf('|', selectionOffset); + chunk.before += table.slice(0, selectionOffset); + chunk.selection = table.slice(selectionOffset, pipePos); + chunk.after = table.slice(pipePos) + chunk.after; + } +}; + +commandProto.doHeading = function (chunk) { + + // Remove leading/trailing whitespace and reduce internal spaces to single spaces. + chunk.selection = chunk.selection.replace(/\s+/g, " "); + chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, ""); + + // If we clicked the button with no selected text, we just + // make a level 2 hash header around some default text. + if (!chunk.selection) { + chunk.startTag = "## "; + chunk.selection = this.getString("headingexample"); + return; + } + + var headerLevel = 0; // The existing header level of the selected text. + + // Remove any existing hash heading markdown and save the header level. + chunk.findTags(/#+[ ]*/, /[ ]*#+/); + if (/#+/.test(chunk.startTag)) { + headerLevel = re.lastMatch.length; + } + chunk.startTag = chunk.endTag = ""; + + // Try to get the current header level by looking for - and = in the line + // below the selection. + chunk.findTags(null, /\s?(-+|=+)/); + if (/=+/.test(chunk.endTag)) { + headerLevel = 1; + } + if (/-+/.test(chunk.endTag)) { + headerLevel = 2; + } + + // Skip to the next line so we can create the header markdown. + chunk.startTag = chunk.endTag = ""; + chunk.skipLines(1, 1); + + // We make a level 2 header if there is no current header. + // If there is a header level, we substract one from the header level. + // If it's already a level 1 header, it's removed. + var headerLevelToCreate = headerLevel === 0 ? 2 : headerLevel - 1; + + if (headerLevelToCreate > 0) { + + chunk.startTag = ''; + while (headerLevelToCreate--) { + chunk.startTag += '#'; + } + chunk.startTag += ' '; + } +}; + +commandProto.doHorizontalRule = function (chunk) { + chunk.startTag = "----------\n"; + chunk.selection = ""; + chunk.skipLines(2, 1, true); +}; + +export default function (options) { + return new Pagedown(options); +}; diff --git a/src/services/animationSvc.js b/src/services/animationSvc.js new file mode 100644 index 0000000..49ae61b --- /dev/null +++ b/src/services/animationSvc.js @@ -0,0 +1,237 @@ +import bezierEasing from 'bezier-easing'; + +const easings = { + materialIn: bezierEasing(0.75, 0, 0.8, 0.25), + materialOut: bezierEasing(0.25, 0.8, 0.25, 1), + inOut: bezierEasing(0.25, 0.1, 0.67, 1), +}; + +const vendors = ['moz', 'webkit']; +for (let x = 0; x < vendors.length && !window.requestAnimationFrame; x += 1) { + window.requestAnimationFrame = window[`${vendors[x]}RequestAnimationFrame`]; + window.cancelAnimationFrame = window[`${vendors[x]}CancelAnimationFrame`] || + window[`${vendors[x]}CancelRequestAnimationFrame`]; +} + +const transformStyles = [ + 'WebkitTransform', + 'MozTransform', + 'msTransform', + 'OTransform', + 'transform', +]; + +const transitionEndEvents = { + WebkitTransition: 'webkitTransitionEnd', + MozTransition: 'transitionend', + msTransition: 'MSTransitionEnd', + OTransition: 'oTransitionEnd', + transition: 'transitionend', +}; + +function getStyle(styles) { + const elt = document.createElement('div'); + return styles.reduce((result, style) => { + if (elt.style[style] === undefined) { + return undefined; + } + return style; + }, undefined); +} + +const transformStyle = getStyle(transformStyles); +const transitionStyle = getStyle(Object.keys(transitionEndEvents)); +const transitionEndEvent = transitionEndEvents[transitionStyle]; + +function identity(x) { + return x; +} + +function ElementAttribute(name) { + this.name = name; + this.setStart = (animation) => { + const value = animation.elt[name]; + animation.$start[name] = value; + return value !== undefined && animation.$end[name] !== undefined; + }; + this.applyCurrent = (animation) => { + animation.elt[name] = animation.$current[name]; + }; +} + +function StyleAttribute(name, unit, defaultValue, wrap = identity) { + this.name = name; + this.setStart = (animation) => { + let value = parseFloat(animation.elt.style[name]); + if (Number.isNaN(value)) { + value = animation.$current[name] || defaultValue; + } + animation.$start[name] = value; + return animation.$end[name] !== undefined; + }; + this.applyCurrent = (animation) => { + animation.elt.style[name] = wrap(animation.$current[name]) + unit; + }; +} + +function TransformAttribute(name, unit, defaultValue, wrap = identity) { + this.name = name; + this.setStart = (animation) => { + let value = animation.$current[name]; + if (value === undefined) { + value = defaultValue; + } + animation.$start[name] = value; + if (animation.$end[name] === undefined) { + animation.$end[name] = value; + } + return value !== undefined; + }; + this.applyCurrent = (animation) => { + const value = animation.$current[name]; + return value !== defaultValue && `${name}(${wrap(value)}${unit})`; + }; +} + +const attributes = [ + new ElementAttribute('scrollTop'), + new ElementAttribute('scrollLeft'), + new StyleAttribute('opacity', '', 1), + new StyleAttribute('zIndex', '', 0), + new TransformAttribute('translateX', 'px', 0, Math.round), + new TransformAttribute('translateY', 'px', 0, Math.round), + new TransformAttribute('scale', '', 1), + new TransformAttribute('rotate', 'deg', 0), +].concat([ + 'width', + 'height', + 'top', + 'right', + 'bottom', + 'left', +].map(name => new StyleAttribute(name, 'px', 0, Math.round))); + +class Animation { + constructor(elt) { + this.elt = elt; + this.$current = {}; + this.$pending = {}; + } + + start(param1, param2, param3) { + let endCb = param1; + let stepCb = param2; + let useTransition = false; + if (typeof param1 === 'boolean') { + useTransition = param1; + endCb = param2; + stepCb = param3; + } + + this.stop(); + this.$start = {}; + this.$end = this.$pending; + this.$pending = {}; + this.$attributes = attributes.filter(attribute => attribute.setStart(this)); + this.$end.duration = this.$end.duration || 0; + this.$end.delay = this.$end.delay || 0; + this.$end.easing = easings[this.$end.easing] || easings.materialOut; + this.$end.endCb = typeof endCb === 'function' && endCb; + this.$end.stepCb = typeof stepCb === 'function' && stepCb; + this.$startTime = Date.now() + this.$end.delay; + if (!this.$end.duration) { + this.loop(false); + } else if (useTransition) { + this.loop(true); + } else { + this.$requestId = window.requestAnimationFrame(() => this.loop(false)); + } + return this.elt; + } + + stop() { + window.cancelAnimationFrame(this.$requestId); + } + + loop(useTransition) { + const onTransitionEnd = (evt) => { + if (evt.target === this.elt) { + this.elt.removeEventListener(transitionEndEvent, onTransitionEnd); + const { endCb } = this.$end; + this.$end.endCb = undefined; + if (endCb) { + endCb(); + } + } + }; + + let progress = (Date.now() - this.$startTime) / this.$end.duration; + let transition = ''; + if (useTransition) { + progress = 1; + const transitions = [ + 'all', + `${this.$end.duration}ms`, + this.$end.easing.toCSS(), + ]; + if (this.$end.delay) { + transitions.push(`${this.$end.duration}ms`); + } + transition = transitions.join(' '); + if (this.$end.endCb) { + this.elt.addEventListener(transitionEndEvent, onTransitionEnd); + } + } else if (progress < 1) { + this.$requestId = window.requestAnimationFrame(() => this.loop(false)); + if (progress < 0) { + return; + } + } else if (this.$end.endCb) { + this.$requestId = window.requestAnimationFrame(this.$end.endCb); + } + + const coeff = this.$end.easing.get(progress); + const transforms = this.$attributes.reduce((result, attribute) => { + if (progress < 1) { + const diff = this.$end[attribute.name] - this.$start[attribute.name]; + this.$current[attribute.name] = this.$start[attribute.name] + (diff * coeff); + } else { + this.$current[attribute.name] = this.$end[attribute.name]; + } + const transform = attribute.applyCurrent(this); + if (transform) { + result.push(transform); + } + return result; + }, []); + + if (transforms.length) { + transforms.push('translateZ(0)'); // activate GPU + } + const transform = transforms.join(' '); + this.elt.style[transformStyle] = transform; + this.elt.style[transitionStyle] = transition; + if (this.$end.stepCb) { + this.$end.stepCb(); + } + } +} + +attributes.map(attribute => attribute.name).concat('duration', 'easing', 'delay') + .forEach((name) => { + Animation.prototype[name] = function setter(val) { + this.$pending[name] = val; + return this; + }; + }); + +function animate(elt) { + if (!elt.$animation) { + elt.$animation = new Animation(elt); + } + return elt.$animation; +} + +export default { + animate, +}; diff --git a/src/services/backupSvc.js b/src/services/backupSvc.js new file mode 100644 index 0000000..3d9070f --- /dev/null +++ b/src/services/backupSvc.js @@ -0,0 +1,74 @@ +import workspaceSvc from './workspaceSvc'; +import utils from './utils'; + +export default { + async importBackup(jsonValue) { + const fileNameMap = {}; + const folderNameMap = {}; + const parentIdMap = {}; + const textMap = {}; + const propertiesMap = {}; + const discussionsMap = {}; + const commentsMap = {}; + const folderIdMap = { + trash: 'trash', + }; + + // Parse JSON value + const parsedValue = JSON.parse(jsonValue); + Object.entries(parsedValue).forEach(([id, value]) => { + if (value) { + const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/); + if (v4Match) { + // StackEdit v4 format + const [, v4Id, type] = v4Match; + if (type === 'title') { + fileNameMap[v4Id] = value; + } else if (type === 'content') { + textMap[v4Id] = value; + } + } else if (value.type === 'folder') { + // StackEdit v5 folder + folderIdMap[id] = utils.uid(); + folderNameMap[id] = value.name; + parentIdMap[id] = `${value.parentId || ''}`; + } else if (value.type === 'file') { + // StackEdit v5 file + fileNameMap[id] = value.name; + parentIdMap[id] = `${value.parentId || ''}`; + } else if (value.type === 'content') { + // StackEdit v5 content + const [fileId] = id.split('/'); + if (fileId) { + textMap[fileId] = value.text; + propertiesMap[fileId] = value.properties; + discussionsMap[fileId] = value.discussions; + commentsMap[fileId] = value.comments; + } + } + } + }); + + await utils.awaitSequence( + Object.keys(folderNameMap), + async externalId => workspaceSvc.setOrPatchItem({ + id: folderIdMap[externalId], + type: 'folder', + name: folderNameMap[externalId], + parentId: folderIdMap[parentIdMap[externalId]], + }), + ); + + await utils.awaitSequence( + Object.keys(fileNameMap), + async externalId => workspaceSvc.createFile({ + name: fileNameMap[externalId], + parentId: folderIdMap[parentIdMap[externalId]], + text: textMap[externalId], + properties: propertiesMap[externalId], + discussions: discussionsMap[externalId], + comments: commentsMap[externalId], + }, true), + ); + }, +}; diff --git a/src/services/badgeSvc.js b/src/services/badgeSvc.js new file mode 100644 index 0000000..964aea8 --- /dev/null +++ b/src/services/badgeSvc.js @@ -0,0 +1,37 @@ +import store from '../store'; + +let lastEarnedFeatureIds = null; +let debounceTimeoutId; + +const showInfo = () => { + const earnedBadges = store.getters['data/allBadges'] + .filter(badge => badge.isEarned && !lastEarnedFeatureIds.has(badge.featureId)); + if (earnedBadges.length) { + store.dispatch('notification/badge', earnedBadges.length > 1 + ? `您已获得 ${earnedBadges.length} 个徽章: ${earnedBadges.map(badge => `"${badge.name}"`).join(', ')}.` + : `您已获得 1 个徽章: "${earnedBadges[0].name}".`); + } + lastEarnedFeatureIds = null; +}; + +export default { + addBadge(featureId) { + if (!store.getters['data/badgeCreations'][featureId]) { + if (!lastEarnedFeatureIds) { + const earnedFeatureIds = store.getters['data/allBadges'] + .filter(badge => badge.isEarned) + .map(badge => badge.featureId); + lastEarnedFeatureIds = new Set(earnedFeatureIds); + } + + store.dispatch('data/patchBadgeCreations', { + [featureId]: { + created: Date.now(), + }, + }); + + clearTimeout(debounceTimeoutId); + debounceTimeoutId = setTimeout(() => showInfo(), 5000); + } + }, +}; diff --git a/src/services/chatGptSvc.js b/src/services/chatGptSvc.js new file mode 100644 index 0000000..0bd8c3b --- /dev/null +++ b/src/services/chatGptSvc.js @@ -0,0 +1,42 @@ +import store from '../store'; + +export default { + chat({ content }, callback) { + const xhr = new XMLHttpRequest(); + const url = 'https://api.openai-proxy.com/v1/chat/completions'; + xhr.open('POST', url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Authorization', `Bearer ${window.my_api_key}`); + xhr.send(JSON.stringify({ + model: 'gpt-3.5-turbo', + max_tokens: 3000, + top_p: 0, + temperature: 0.9, + frequency_penalty: 0, + presence_penalty: 0, + messages: [{ role: 'user', content }], + stream: true, + })); + let lastRespLen = 0; + xhr.onprogress = () => { + const responseText = xhr.response.substr(lastRespLen); + lastRespLen = xhr.response.length; + responseText.split('\n\n') + .filter(l => l.length > 0) + .forEach((text) => { + const item = text.substr(6); + if (item === '[DONE]') { + callback({ done: true }); + } else { + const data = JSON.parse(item); + callback({ content: data.choices[0].delta.content }); + } + }); + }; + xhr.onerror = () => { + store.dispatch('notification/error', 'ChatGPT接口请求异常!'); + callback({ error: 'ChatGPT接口请求异常!' }); + }; + return xhr; + }, +}; diff --git a/src/services/diffUtils.js b/src/services/diffUtils.js new file mode 100644 index 0000000..2bc1b19 --- /dev/null +++ b/src/services/diffUtils.js @@ -0,0 +1,201 @@ +import DiffMatchPatch from 'diff-match-patch'; +import utils from './utils'; + +const diffMatchPatch = new DiffMatchPatch(); +diffMatchPatch.Match_Distance = 10000; + +function makePatchableText(content, markerKeys, markerIdxMap) { + if (!content || !content.discussions) { + return null; + } + const markers = []; + // Sort keys to have predictable marker positions in case of same offset + const discussionKeys = Object.keys(content.discussions).sort(); + discussionKeys.forEach((discussionId) => { + const discussion = content.discussions[discussionId]; + + function addMarker(offsetName) { + const markerKey = discussionId + offsetName; + if (discussion[offsetName] !== undefined) { + let idx = markerIdxMap[markerKey]; + if (idx === undefined) { + idx = markerKeys.length; + markerIdxMap[markerKey] = idx; + markerKeys.push({ + id: discussionId, + offsetName, + }); + } + markers.push({ + idx, + offset: discussion[offsetName], + }); + } + } + + addMarker('start'); + addMarker('end'); + }); + + let lastOffset = 0; + let result = ''; + markers + .sort((marker1, marker2) => marker1.offset - marker2.offset) + .forEach((marker) => { + result += + content.text.slice(lastOffset, marker.offset) + + String.fromCharCode(0xe000 + marker.idx); // Use a character from the private use area + lastOffset = marker.offset; + }); + return result + content.text.slice(lastOffset); +} + +function stripDiscussionOffsets(objectMap) { + if (objectMap == null) { + return objectMap; + } + const result = {}; + Object.keys(objectMap).forEach((id) => { + result[id] = { + text: objectMap[id].text, + }; + }); + return result; +} + +function restoreDiscussionOffsets(content, markerKeys) { + if (markerKeys.length) { + // Go through markers + let count = 0; + content.text = content.text.replace( + new RegExp(`[\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'), + (match, offset) => { + const idx = match.charCodeAt(0) - 0xe000; + const markerKey = markerKeys[idx]; + const discussion = content.discussions[markerKey.id]; + if (discussion) { + discussion[markerKey.offsetName] = offset - count; + } + count += 1; + return ''; + }, + ); + // Sanitize offsets + Object.keys(content.discussions).forEach((discussionId) => { + const discussion = content.discussions[discussionId]; + if (discussion.start === undefined) { + discussion.start = discussion.end || 0; + } + if (discussion.end === undefined || discussion.end < discussion.start) { + discussion.end = discussion.start; + } + }); + } +} + +function mergeText(serverText, clientText, lastMergedText) { + const serverClientDiffs = diffMatchPatch.diff_main(serverText, clientText); + diffMatchPatch.diff_cleanupSemantic(serverClientDiffs); + // Fusion text is a mix of both server and client contents + const fusionText = serverClientDiffs.map(diff => diff[1]).join(''); + if (!lastMergedText) { + return fusionText; + } + // Let's try to find out what text has to be removed from fusion + const intersectionText = serverClientDiffs + // Keep only equalities + .filter(diff => diff[0] === DiffMatchPatch.DIFF_EQUAL) + .map(diff => diff[1]).join(''); + const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText) + // Keep only equalities and deletions + .filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT); + diffMatchPatch.diff_cleanupSemantic(lastMergedTextDiffs); + // Make a patch with deletions only + const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs); + // Apply patch to fusion text + return diffMatchPatch.patch_apply(patches, fusionText)[0]; +} + +function mergeValues(serverValue, clientValue, lastMergedValue) { + if (!lastMergedValue) { + return serverValue || clientValue; // Take the server value in priority + } + const newSerializedValue = utils.serializeObject(clientValue); + const serverSerializedValue = utils.serializeObject(serverValue); + if (newSerializedValue === serverSerializedValue) { + return serverValue; // no conflict + } + const oldSerializedValue = utils.serializeObject(lastMergedValue); + if (oldSerializedValue !== newSerializedValue && !serverValue) { + return clientValue; // Removed on server but changed on client + } + if (oldSerializedValue !== serverSerializedValue && !clientValue) { + return serverValue; // Removed on client but changed on server + } + if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) { + return clientValue; // Take the client value + } + return serverValue; // Take the server value +} + +function mergeObjects(serverObject, clientObject, lastMergedObject = {}) { + const mergedObject = {}; + Object.keys({ + ...clientObject, + ...serverObject, + }).forEach((key) => { + const mergedValue = mergeValues(serverObject[key], clientObject[key], lastMergedObject[key]); + if (mergedValue != null) { + mergedObject[key] = mergedValue; + } + }); + return utils.deepCopy(mergedObject); +} + +function mergeContent(serverContent, clientContent, lastMergedContent = {}) { + const markerKeys = []; + const markerIdxMap = Object.create(null); + const lastMergedText = makePatchableText(lastMergedContent, markerKeys, markerIdxMap); + const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap); + const clientText = makePatchableText(clientContent, markerKeys, markerIdxMap); + const isServerTextChanges = lastMergedText !== serverText; + const isClientTextChanges = lastMergedText !== clientText; + const isTextSynchronized = serverText === clientText; + let text = clientText; + if (!isTextSynchronized && isServerTextChanges) { + text = serverText; + if (isClientTextChanges) { + text = mergeText(serverText, clientText, lastMergedText); + } + } + + const result = { + text, + properties: mergeValues( + serverContent.properties, + clientContent.properties, + lastMergedContent.properties, + ), + discussions: mergeObjects( + stripDiscussionOffsets(serverContent.discussions), + stripDiscussionOffsets(clientContent.discussions), + stripDiscussionOffsets(lastMergedContent.discussions), + ), + comments: mergeObjects( + serverContent.comments, + clientContent.comments, + lastMergedContent.comments, + ), + // 服务端和本地都变更了 + mergeFlag: isServerTextChanges && isClientTextChanges, + }; + restoreDiscussionOffsets(result, markerKeys); + return result; +} + +export default { + makePatchableText, + restoreDiscussionOffsets, + mergeObjects, + mergeContent, +}; diff --git a/src/services/editor/cledit/cleditCore.js b/src/services/editor/cledit/cleditCore.js new file mode 100644 index 0000000..79ca06a --- /dev/null +++ b/src/services/editor/cledit/cleditCore.js @@ -0,0 +1,445 @@ +import DiffMatchPatch from 'diff-match-patch'; +import TurndownService from 'turndown/lib/turndown.browser.umd'; +import htmlSanitizer from '../../../libs/htmlSanitizer'; +import store from '../../../store'; + +function cledit(contentElt, scrollEltOpt, isMarkdown = false) { + const scrollElt = scrollEltOpt || contentElt; + const editor = { + $contentElt: contentElt, + $scrollElt: scrollElt, + $keystrokes: [], + $markers: {}, + }; + cledit.Utils.createEventHooks(editor); + const { debounce } = cledit.Utils; + + contentElt.setAttribute('tabindex', '0'); // To have focus even when disabled + editor.toggleEditable = (isEditable) => { + contentElt.contentEditable = isEditable == null ? !contentElt.contentEditable : isEditable; + }; + editor.toggleEditable(true); + + function getTextContent() { + // Markdown-it sanitization (Mac/DOS to Unix) + let textContent = contentElt.textContent.replace(/\r[\n\u0085]?|[\u2424\u2028\u0085]/g, '\n'); + if (textContent.slice(-1) !== '\n') { + textContent += '\n'; + } + return textContent; + } + + let lastTextContent = getTextContent(); + const highlighter = new cledit.Highlighter(editor); + + /* eslint-disable new-cap */ + const diffMatchPatch = new DiffMatchPatch(); + /* eslint-enable new-cap */ + const selectionMgr = new cledit.SelectionMgr(editor); + + function adjustCursorPosition(force) { + selectionMgr.saveSelectionState(true, true, force); + } + + function replaceContent(selectionStart, selectionEnd, replacement) { + const min = Math.min(selectionStart, selectionEnd); + const max = Math.max(selectionStart, selectionEnd); + const range = selectionMgr.createRange(min, max); + const rangeText = `${range}`; + // Range can contain a br element, which is not taken into account in rangeText + if (rangeText.length === max - min && rangeText === replacement) { + return null; + } + range.deleteContents(); + range.insertNode(document.createTextNode(replacement)); + return range; + } + + let ignoreUndo = false; + let noContentFix = false; + + function setContent(value, noUndo, maxStartOffsetOpt) { + const textContent = getTextContent(); + const maxStartOffset = maxStartOffsetOpt != null && maxStartOffsetOpt < textContent.length + ? maxStartOffsetOpt + : textContent.length - 1; + const startOffset = Math.min( + diffMatchPatch.diff_commonPrefix(textContent, value), + maxStartOffset, + ); + const endOffset = Math.min( + diffMatchPatch.diff_commonSuffix(textContent, value), + textContent.length - startOffset, + value.length - startOffset, + ); + const replacement = value.substring(startOffset, value.length - endOffset); + const range = replaceContent(startOffset, textContent.length - endOffset, replacement); + if (range) { + ignoreUndo = noUndo; + noContentFix = true; + } + return { + start: startOffset, + end: value.length - endOffset, + range, + }; + } + + const undoMgr = new cledit.UndoMgr(editor); + + function replace(selectionStart, selectionEnd, replacement) { + undoMgr.setDefaultMode('single'); + replaceContent(selectionStart, selectionEnd, replacement); + const startOffset = Math.min(selectionStart, selectionEnd); + const endOffset = startOffset + replacement.length; + selectionMgr.setSelectionStartEnd(endOffset, endOffset); + selectionMgr.updateCursorCoordinates(true); + } + + function replaceAll(search, replacement, startOffset = 0) { + undoMgr.setDefaultMode('single'); + const text = getTextContent(); + const subtext = getTextContent().slice(startOffset); + const value = subtext.replace(search, replacement); + if (value !== subtext) { + const offset = editor.setContent(text.slice(0, startOffset) + value); + selectionMgr.setSelectionStartEnd(offset.end, offset.end); + selectionMgr.updateCursorCoordinates(true); + } + } + + function focus() { + selectionMgr.restoreSelection(); + contentElt.focus(); + } + + function addMarker(marker) { + editor.$markers[marker.id] = marker; + } + + function removeMarker(marker) { + delete editor.$markers[marker.id]; + } + + const triggerSpellCheck = debounce(() => { + // Hack for Chrome to trigger the spell checker + const selection = window.getSelection(); + if (selectionMgr.hasFocus() + && !highlighter.isComposing + && selectionMgr.selectionStart === selectionMgr.selectionEnd + && selection.modify + ) { + if (selectionMgr.selectionStart) { + selection.modify('move', 'backward', 'character'); + selection.modify('move', 'forward', 'character'); + } else { + selection.modify('move', 'forward', 'character'); + selection.modify('move', 'backward', 'character'); + } + } + }, 10); + + let watcher; + let skipSaveSelection; + function checkContentChange(mutations) { + watcher.noWatch(() => { + const removedSections = []; + const modifiedSections = []; + + function markModifiedSection(node) { + let currentNode = node; + while (currentNode && currentNode !== contentElt) { + if (currentNode.section) { + const array = currentNode.parentNode ? modifiedSections : removedSections; + if (array.indexOf(currentNode.section) === -1) { + array.push(currentNode.section); + } + return; + } + currentNode = currentNode.parentNode; + } + } + + mutations.cl_each((mutation) => { + markModifiedSection(mutation.target); + mutation.addedNodes.cl_each(markModifiedSection); + mutation.removedNodes.cl_each(markModifiedSection); + }); + highlighter.fixContent(modifiedSections, removedSections, noContentFix); + noContentFix = false; + }); + + if (!skipSaveSelection) { + selectionMgr.saveSelectionState(); + } + skipSaveSelection = false; + + const newTextContent = getTextContent(); + const diffs = diffMatchPatch.diff_main(lastTextContent, newTextContent); + editor.$markers.cl_each((marker) => { + marker.adjustOffset(diffs); + }); + + const sectionList = highlighter.parseSections(newTextContent); + editor.$trigger('contentChanged', newTextContent, diffs, sectionList); + if (!ignoreUndo) { + undoMgr.addDiffs(lastTextContent, newTextContent, diffs); + undoMgr.setDefaultMode('typing'); + undoMgr.saveState(); + } + ignoreUndo = false; + lastTextContent = newTextContent; + triggerSpellCheck(); + } + + // Detect editor changes + watcher = new cledit.Watcher(editor, checkContentChange); + watcher.startWatching(); + + function setSelection(start, end) { + selectionMgr.setSelectionStartEnd(start, end == null ? start : end); + selectionMgr.updateCursorCoordinates(); + } + + function keydownHandler(handler) { + return (evt) => { + if ( + evt.which !== 17 && // Ctrl + evt.which !== 91 && // Cmd + evt.which !== 18 && // Alt + evt.which !== 16 // Shift + ) { + handler(evt); + } + }; + } + + let windowKeydownListener; + let windowMouseListener; + let windowResizeListener; + function tryDestroy() { + if (document.contains(contentElt)) { + return false; + } + watcher.stopWatching(); + window.removeEventListener('keydown', windowKeydownListener); + window.removeEventListener('mousedown', windowMouseListener); + window.removeEventListener('mouseup', windowMouseListener); + window.removeEventListener('resize', windowResizeListener); + editor.$trigger('destroy'); + return true; + } + + // In case of Ctrl/Cmd+A outside the editor element + windowKeydownListener = (evt) => { + if (!tryDestroy()) { + keydownHandler(() => { + adjustCursorPosition(); + })(evt); + } + }; + window.addEventListener('keydown', windowKeydownListener); + + // Mouseup can happen outside the editor element + windowMouseListener = () => { + if (!tryDestroy()) { + selectionMgr.saveSelectionState(true, false); + } + }; + window.addEventListener('mousedown', windowMouseListener); + window.addEventListener('mouseup', windowMouseListener); + + // Resize provokes cursor coordinate changes + windowResizeListener = () => { + if (!tryDestroy()) { + selectionMgr.updateCursorCoordinates(); + } + }; + window.addEventListener('resize', windowResizeListener); + + // Provokes selection changes and does not fire mouseup event on Chrome/OSX + contentElt.addEventListener( + 'contextmenu', + selectionMgr.saveSelectionState.cl_bind(selectionMgr, true, false), + ); + + contentElt.addEventListener('keydown', keydownHandler((evt) => { + selectionMgr.saveSelectionState(); + + // Perform keystroke + let contentChanging = false; + const textContent = getTextContent(); + let min = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd); + let max = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd); + const state = { + before: textContent.slice(0, min), + after: textContent.slice(max), + selection: textContent.slice(min, max), + isBackwardSelection: selectionMgr.selectionStart > selectionMgr.selectionEnd, + }; + editor.$keystrokes.cl_some((keystroke) => { + if (!keystroke.handler(evt, state, editor)) { + return false; + } + const newContent = state.before + state.selection + state.after; + if (newContent !== getTextContent()) { + editor.setContent(newContent, false, min); + contentChanging = true; + skipSaveSelection = true; + highlighter.cancelComposition = true; + } + min = state.before.length; + max = min + state.selection.length; + selectionMgr.setSelectionStartEnd( + state.isBackwardSelection ? max : min, + state.isBackwardSelection ? min : max, + !contentChanging, // Expect a restore selection on mutation event + ); + return true; + }); + + if (!contentChanging) { + // Optimization to avoid saving selection + adjustCursorPosition(); + } + })); + + contentElt.addEventListener('compositionstart', () => { + highlighter.isComposing += 1; + }); + + contentElt.addEventListener('compositionend', () => { + setTimeout(() => { + if (highlighter.isComposing) { + highlighter.isComposing -= 1; + if (!highlighter.isComposing) { + checkContentChange([]); + } + } + }, 1); + }); + + let turndownService; + if (isMarkdown) { + contentElt.addEventListener('copy', (evt) => { + if (evt.clipboardData) { + evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText()); + evt.preventDefault(); + } + }); + + contentElt.addEventListener('cut', (evt) => { + if (evt.clipboardData) { + evt.clipboardData.setData('text/plain', selectionMgr.getSelectedText()); + evt.preventDefault(); + replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, ''); + } else { + undoMgr.setCurrentMode('single'); + } + adjustCursorPosition(); + }); + + turndownService = new TurndownService(store.getters['data/computedSettings'].turndown); + turndownService.escape = str => str; // Disable escaping + } + + contentElt.addEventListener('paste', (evt) => { + undoMgr.setCurrentMode('single'); + evt.preventDefault(); + let data; + let { clipboardData } = evt; + if (clipboardData) { + data = clipboardData.getData('text/plain'); + if (turndownService) { + try { + const html = clipboardData.getData('text/html'); + if (html) { + const sanitizedHtml = htmlSanitizer.sanitizeHtml(html) + .replace(/ /g, ' '); // Replace non-breaking spaces with classic spaces + if (sanitizedHtml) { + data = turndownService.turndown(sanitizedHtml); + } + } + } catch (e) { + // Ignore + } + } + } else { + ({ clipboardData } = window.clipboardData); + data = clipboardData && clipboardData.getData('Text'); + } + if (!data) { + return; + } + replace(selectionMgr.selectionStart, selectionMgr.selectionEnd, data); + adjustCursorPosition(); + }); + + contentElt.addEventListener('focus', () => { + editor.$trigger('focus'); + }); + + contentElt.addEventListener('blur', () => { + editor.$trigger('blur'); + }); + + function addKeystroke(keystroke) { + const keystrokes = Array.isArray(keystroke) ? keystroke : [keystroke]; + editor.$keystrokes = editor.$keystrokes + .concat(keystrokes) + .sort((keystroke1, keystroke2) => keystroke1.priority - keystroke2.priority); + } + addKeystroke(cledit.defaultKeystrokes); + + editor.selectionMgr = selectionMgr; + editor.undoMgr = undoMgr; + editor.highlighter = highlighter; + editor.watcher = watcher; + editor.adjustCursorPosition = adjustCursorPosition; + editor.setContent = setContent; + editor.replace = replace; + editor.replaceAll = replaceAll; + editor.getContent = getTextContent; + editor.focus = focus; + editor.setSelection = setSelection; + editor.addKeystroke = addKeystroke; + editor.addMarker = addMarker; + editor.removeMarker = removeMarker; + + editor.init = (opts = {}) => { + const options = ({ + getCursorFocusRatio() { + return 0.1; + }, + sectionHighlighter(section) { + return section.text.replace(/&/g, '&').replace(/ document.head.contains(styleElt))) { + createStyleSheet(document); + } + + const contentElt = editor.$contentElt; + this.isComposing = 0; + + let sectionList = []; + let insertBeforeSection; + const useBr = cledit.Utils.isWebkit; + const trailingNodeTag = 'div'; + const hiddenLfInnerHtml = '
'; + + const lfHtml = `${useBr ? hiddenLfInnerHtml : '\n'}`; + + this.fixContent = (modifiedSections, removedSections, noContentFix) => { + modifiedSections.cl_each((section) => { + section.forceHighlighting = true; + if (!noContentFix) { + if (useBr) { + section.elt.getElementsByClassName('hd-lf') + .cl_each(lfElt => lfElt.parentNode.removeChild(lfElt)); + section.elt.getElementsByTagName('br') + .cl_each(brElt => brElt.parentNode.replaceChild(document.createTextNode('\n'), brElt)); + } + if (section.elt.textContent.slice(-1) !== '\n') { + section.elt.appendChild(document.createTextNode('\n')); + } + } + }); + }; + + this.addTrailingNode = () => { + this.trailingNode = document.createElement(trailingNodeTag); + contentElt.appendChild(this.trailingNode); + }; + + class Section { + constructor(text) { + this.text = text.text === undefined ? text : text.text; + this.data = text.data; + } + setElement(elt) { + this.elt = elt; + elt.section = this; + } + } + + this.parseSections = (content, isInit) => { + if (this.isComposing && !this.cancelComposition) { + return sectionList; + } + + this.cancelComposition = false; + const newSectionList = (editor.options.sectionParser + ? editor.options.sectionParser(content) + : [content]) + .cl_map(sectionText => new Section(sectionText)); + + let modifiedSections = []; + let sectionsToRemove = []; + insertBeforeSection = undefined; + + if (isInit) { + // Render everything if isInit + sectionsToRemove = sectionList; + sectionList = newSectionList; + modifiedSections = newSectionList; + } else { + // Find modified section starting from top + let leftIndex = sectionList.length; + sectionList.cl_some((section, index) => { + const newSection = newSectionList[index]; + if (index >= newSectionList.length || + section.forceHighlighting || + // Check text modification + section.text !== newSection.text || + // Check that section has not been detached or moved + section.elt.parentNode !== contentElt || + // Check also the content since nodes can be injected in sections via copy/paste + section.elt.textContent !== newSection.text + ) { + leftIndex = index; + return true; + } + return false; + }); + + // Find modified section starting from bottom + let rightIndex = -sectionList.length; + sectionList.slice().reverse().cl_some((section, index) => { + const newSection = newSectionList[newSectionList.length - index - 1]; + if (index >= newSectionList.length || + section.forceHighlighting || + // Check modified + section.text !== newSection.text || + // Check that section has not been detached or moved + section.elt.parentNode !== contentElt || + // Check also the content since nodes can be injected in sections via copy/paste + section.elt.textContent !== newSection.text + ) { + rightIndex = -index; + return true; + } + return false; + }); + + if (leftIndex - rightIndex > sectionList.length) { + // Prevent overlap + rightIndex = leftIndex - sectionList.length; + } + + const leftSections = sectionList.slice(0, leftIndex); + modifiedSections = newSectionList.slice(leftIndex, newSectionList.length + rightIndex); + const rightSections = sectionList.slice(sectionList.length + rightIndex, sectionList.length); + [insertBeforeSection] = rightSections; + sectionsToRemove = sectionList.slice(leftIndex, sectionList.length + rightIndex); + sectionList = leftSections.concat(modifiedSections).concat(rightSections); + } + + const highlight = (section) => { + const html = editor.options.sectionHighlighter(section).replace(/\n/g, lfHtml); + const sectionElt = document.createElement('div'); + sectionElt.className = 'cledit-section'; + sectionElt.innerHTML = html; + section.setElement(sectionElt); + this.$trigger('sectionHighlighted', section); + }; + + const newSectionEltList = document.createDocumentFragment(); + modifiedSections.cl_each((section) => { + section.forceHighlighting = false; + highlight(section); + newSectionEltList.appendChild(section.elt); + }); + editor.watcher.noWatch(() => { + if (isInit) { + contentElt.innerHTML = ''; + contentElt.appendChild(newSectionEltList); + this.addTrailingNode(); + return; + } + + // Remove outdated sections + sectionsToRemove.cl_each((section) => { + // section may be already removed + if (section.elt.parentNode === contentElt) { + contentElt.removeChild(section.elt); + } + // To detect sections that come back with built-in undo + section.elt.section = undefined; + }); + + if (insertBeforeSection !== undefined) { + contentElt.insertBefore(newSectionEltList, insertBeforeSection.elt); + } else { + contentElt.appendChild(newSectionEltList); + } + + // Remove unauthorized nodes (text nodes outside of sections or + // duplicated sections via copy/paste) + let childNode = contentElt.firstChild; + while (childNode) { + const nextNode = childNode.nextSibling; + if (!childNode.section) { + contentElt.removeChild(childNode); + } + childNode = nextNode; + } + this.addTrailingNode(); + this.$trigger('highlighted'); + + if (editor.selectionMgr.hasFocus()) { + editor.selectionMgr.restoreSelection(); + editor.selectionMgr.updateCursorCoordinates(); + } + }); + + return sectionList; + }; +} + +cledit.Highlighter = Highlighter; + diff --git a/src/services/editor/cledit/cleditKeystroke.js b/src/services/editor/cledit/cleditKeystroke.js new file mode 100644 index 0000000..b2d6387 --- /dev/null +++ b/src/services/editor/cledit/cleditKeystroke.js @@ -0,0 +1,188 @@ +import cledit from './cleditCore'; + +function Keystroke(handler, priority) { + this.handler = handler; + this.priority = priority || 100; +} + +cledit.Keystroke = Keystroke; + +let clearNewline; +const charTypes = Object.create(null); + +// Word separators, as in Sublime Text +'./\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~?'.split('').cl_each((wordSeparator) => { + charTypes[wordSeparator] = 'wordSeparator'; +}); +charTypes[' '] = 'space'; +charTypes['\t'] = 'space'; +charTypes['\n'] = 'newLine'; + +function getNextWordOffset(text, offset, isBackward) { + let previousType; + let result = offset; + while ((isBackward && result > 0) || (!isBackward && result < text.length)) { + const currentType = charTypes[isBackward ? text[result - 1] : text[result]] || 'word'; + if (previousType && currentType !== previousType) { + if (previousType === 'word' || currentType === 'space' || previousType === 'newLine' || currentType === 'newLine') { + break; + } + } + previousType = currentType; + if (isBackward) { + result -= 1; + } else { + result += 1; + } + } + return result; +} + +cledit.defaultKeystrokes = [ + + new Keystroke((evt, state, editor) => { + if ((!evt.ctrlKey && !evt.metaKey) || evt.altKey) { + return false; + } + const keyCode = evt.charCode || evt.keyCode; + const keyCodeChar = String.fromCharCode(keyCode).toLowerCase(); + let action; + switch (keyCodeChar) { + case 'y': + action = 'redo'; + break; + case 'z': + action = evt.shiftKey ? 'redo' : 'undo'; + break; + default: + } + if (action) { + evt.preventDefault(); + setTimeout(() => editor.undoMgr[action](), 10); + return true; + } + return false; + }), + + new Keystroke((evt, state) => { + if (evt.which !== 9 /* tab */ || evt.metaKey || evt.ctrlKey) { + return false; + } + + const strSplice = (str, i, remove, add = '') => + str.slice(0, i) + add + str.slice(i + (+remove || 0)); + + evt.preventDefault(); + const isInverse = evt.shiftKey; + const lf = state.before.lastIndexOf('\n') + 1; + if (isInverse) { + if (/\s/.test(state.before.charAt(lf))) { + state.before = strSplice(state.before, lf, 1); + } + state.selection = state.selection.replace(/^[ \t]/gm, ''); + } else if (state.selection) { + state.before = strSplice(state.before, lf, 0, '\t'); + state.selection = state.selection.replace(/\n(?=[\s\S])/g, '\n\t'); + } else { + state.before += '\t'; + } + return true; + }), + + new Keystroke((evt, state, editor) => { + if (evt.which !== 13 /* enter */) { + clearNewline = false; + return false; + } + + evt.preventDefault(); + const lf = state.before.lastIndexOf('\n') + 1; + if (clearNewline) { + state.before = state.before.substring(0, lf); + state.selection = ''; + clearNewline = false; + return true; + } + clearNewline = false; + const previousLine = state.before.slice(lf); + const indent = previousLine.match(/^\s*/)[0]; + if (indent.length) { + clearNewline = true; + } + + editor.undoMgr.setCurrentMode('single'); + state.before += `\n${indent}`; + state.selection = ''; + return true; + }), + + new Keystroke((evt, state, editor) => { + if (evt.which !== 8 /* backspace */ && evt.which !== 46 /* delete */) { + return false; + } + + editor.undoMgr.setCurrentMode('delete'); + if (!state.selection) { + const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey); + if (isJump) { + // Custom kill word behavior + const text = state.before + state.after; + const offset = getNextWordOffset(text, state.before.length, evt.which === 8); + if (evt.which === 8) { + state.before = state.before.slice(0, offset); + } else { + state.after = state.after.slice(offset - text.length); + } + evt.preventDefault(); + return true; + } else if (evt.which === 8 && state.before.slice(-1) === '\n') { + // Special treatment for end of lines + state.before = state.before.slice(0, -1); + evt.preventDefault(); + return true; + } else if (evt.which === 46 && state.after.slice(0, 1) === '\n') { + state.after = state.after.slice(1); + evt.preventDefault(); + return true; + } + } else { + state.selection = ''; + evt.preventDefault(); + return true; + } + return false; + }), + + new Keystroke((evt, state, editor) => { + if (evt.which !== 37 /* left arrow */ && evt.which !== 39 /* right arrow */) { + return false; + } + const isJump = (cledit.Utils.isMac && evt.altKey) || (!cledit.Utils.isMac && evt.ctrlKey); + if (!isJump) { + return false; + } + + // Custom jump behavior + const textContent = editor.getContent(); + const offset = getNextWordOffset( + textContent, + editor.selectionMgr.selectionEnd, + evt.which === 37, + ); + if (evt.shiftKey) { + // rebuild the state completely + const min = Math.min(editor.selectionMgr.selectionStart, offset); + const max = Math.max(editor.selectionMgr.selectionStart, offset); + state.before = textContent.slice(0, min); + state.after = textContent.slice(max); + state.selection = textContent.slice(min, max); + state.isBackwardSelection = editor.selectionMgr.selectionStart > offset; + } else { + state.before = textContent.slice(0, offset); + state.after = textContent.slice(offset); + state.selection = ''; + } + evt.preventDefault(); + return true; + }), +]; diff --git a/src/services/editor/cledit/cleditMarker.js b/src/services/editor/cledit/cleditMarker.js new file mode 100644 index 0000000..f4ff550 --- /dev/null +++ b/src/services/editor/cledit/cleditMarker.js @@ -0,0 +1,49 @@ +import cledit from './cleditCore'; + +const DIFF_DELETE = -1; +const DIFF_INSERT = 1; +const DIFF_EQUAL = 0; + +let idCounter = 0; + +class Marker { + constructor(offset, trailing) { + this.id = idCounter; + idCounter += 1; + this.offset = offset; + this.trailing = trailing; + } + + adjustOffset(diffs) { + let startOffset = 0; + diffs.cl_each((diff) => { + const diffType = diff[0]; + const diffText = diff[1]; + const diffOffset = diffText.length; + switch (diffType) { + case DIFF_EQUAL: + startOffset += diffOffset; + break; + case DIFF_INSERT: + if ( + this.trailing + ? this.offset > startOffset + : this.offset >= startOffset + ) { + this.offset += diffOffset; + } + startOffset += diffOffset; + break; + case DIFF_DELETE: + if (this.offset > startOffset) { + this.offset -= Math.min(diffOffset, this.offset - startOffset); + } + break; + default: + } + }); + } +} + + +cledit.Marker = Marker; diff --git a/src/services/editor/cledit/cleditSelectionMgr.js b/src/services/editor/cledit/cleditSelectionMgr.js new file mode 100644 index 0000000..56a8170 --- /dev/null +++ b/src/services/editor/cledit/cleditSelectionMgr.js @@ -0,0 +1,431 @@ +import cledit from './cleditCore'; + +function SelectionMgr(editor) { + const { debounce } = cledit.Utils; + const contentElt = editor.$contentElt; + const scrollElt = editor.$scrollElt; + cledit.Utils.createEventHooks(this); + + let lastSelectionStart = 0; + let lastSelectionEnd = 0; + this.selectionStart = 0; + this.selectionEnd = 0; + this.cursorCoordinates = {}; + + this.findContainer = (offset) => { + const result = cledit.Utils.findContainer(contentElt, offset); + if (result.container.nodeValue === '\n') { + const hdLfElt = result.container.parentNode; + if (hdLfElt.className === 'hd-lf' && hdLfElt.previousSibling && hdLfElt.previousSibling.tagName === 'BR') { + result.container = hdLfElt.parentNode; + result.offsetInContainer = Array.prototype.indexOf.call( + result.container.childNodes, + result.offsetInContainer === 0 ? hdLfElt.previousSibling : hdLfElt, + ); + } + } + return result; + }; + + this.createRange = (start, end) => { + const range = document.createRange(); + const startContainer = typeof start === 'number' + ? this.findContainer(start < 0 ? 0 : start) + : start; + let endContainer = startContainer; + if (start !== end) { + endContainer = typeof end === 'number' + ? this.findContainer(end < 0 ? 0 : end) + : end; + } + range.setStart(startContainer.container, startContainer.offsetInContainer); + range.setEnd(endContainer.container, endContainer.offsetInContainer); + return range; + }; + + let adjustScroll; + const debouncedUpdateCursorCoordinates = debounce(() => { + const coordinates = this.getCoordinates( + this.selectionEnd, + this.selectionEndContainer, + this.selectionEndOffset, + ); + if (this.cursorCoordinates.top !== coordinates.top || + this.cursorCoordinates.height !== coordinates.height || + this.cursorCoordinates.left !== coordinates.left + ) { + this.cursorCoordinates = coordinates; + this.$trigger('cursorCoordinatesChanged', coordinates); + } + if (adjustScroll) { + let scrollEltHeight = scrollElt.clientHeight; + if (typeof adjustScroll === 'number') { + scrollEltHeight -= adjustScroll; + } + const adjustment = (scrollEltHeight / 2) * editor.options.getCursorFocusRatio(); + let cursorTop = this.cursorCoordinates.top + (this.cursorCoordinates.height / 2); + // Adjust cursorTop with contentElt position relative to scrollElt + cursorTop += (contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top) + + scrollElt.scrollTop; + const minScrollTop = cursorTop - adjustment; + const maxScrollTop = (cursorTop + adjustment) - scrollEltHeight; + if (scrollElt.scrollTop > minScrollTop) { + scrollElt.scrollTop = minScrollTop; + } else if (scrollElt.scrollTop < maxScrollTop) { + scrollElt.scrollTop = maxScrollTop; + } + } + adjustScroll = false; + }); + + this.updateCursorCoordinates = (adjustScrollParam) => { + adjustScroll = adjustScroll || adjustScrollParam; + debouncedUpdateCursorCoordinates(); + }; + + let oldSelectionRange; + + const checkSelection = (selectionRange) => { + if (!oldSelectionRange || + oldSelectionRange.startContainer !== selectionRange.startContainer || + oldSelectionRange.startOffset !== selectionRange.startOffset || + oldSelectionRange.endContainer !== selectionRange.endContainer || + oldSelectionRange.endOffset !== selectionRange.endOffset + ) { + oldSelectionRange = selectionRange; + this.$trigger('selectionChanged', this.selectionStart, this.selectionEnd, selectionRange); + return true; + } + return false; + }; + + this.hasFocus = () => contentElt === document.activeElement; + + this.restoreSelection = () => { + const min = Math.min(this.selectionStart, this.selectionEnd); + const max = Math.max(this.selectionStart, this.selectionEnd); + const selectionRange = this.createRange(min, max); + if (!document.contains(selectionRange.commonAncestorContainer)) { + return null; + } + const selection = window.getSelection(); + selection.removeAllRanges(); + const isBackward = this.selectionStart > this.selectionEnd; + if (isBackward && selection.extend) { + const beginRange = selectionRange.cloneRange(); + beginRange.collapse(false); + selection.addRange(beginRange); + selection.extend(selectionRange.startContainer, selectionRange.startOffset); + } else { + selection.addRange(selectionRange); + } + checkSelection(selectionRange); + return selectionRange; + }; + + const saveLastSelection = debounce(() => { + lastSelectionStart = this.selectionStart; + lastSelectionEnd = this.selectionEnd; + }, 50); + + const setSelection = (start = this.selectionStart, end = this.selectionEnd) => { + this.selectionStart = start < 0 ? 0 : start; + this.selectionEnd = end < 0 ? 0 : end; + saveLastSelection(); + }; + + this.setSelectionStartEnd = (start, end, restoreSelection = true) => { + setSelection(start, end); + if (restoreSelection && this.hasFocus()) { + return this.restoreSelection(); + } + return null; + }; + + this.saveSelectionState = (() => { + // Credit: https://github.com/timdown/rangy + function arrayContains(arr, val) { + let i = arr.length; + while (i) { + i -= 1; + if (arr[i] === val) { + return true; + } + } + return false; + } + + function getClosestAncestorIn(node, ancestor, selfIsAncestor) { + let p; + let n = selfIsAncestor ? node : node.parentNode; + while (n) { + p = n.parentNode; + if (p === ancestor) { + return n; + } + n = p; + } + return null; + } + + function getNodeIndex(node) { + let i = 0; + let { previousSibling } = node; + while (previousSibling) { + i += 1; + ({ previousSibling } = previousSibling); + } + return i; + } + + function getCommonAncestor(node1, node2) { + const ancestors = []; + let n; + for (n = node1; n; n = n.parentNode) { + ancestors.push(n); + } + + for (n = node2; n; n = n.parentNode) { + if (arrayContains(ancestors, n)) { + return n; + } + } + + return null; + } + + function comparePoints(nodeA, offsetA, nodeB, offsetB) { + // See http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html#Level-2-Range-Comparing + let n; + if (nodeA === nodeB) { + // Case 1: nodes are the same + if (offsetA === offsetB) { + return 0; + } + return offsetA < offsetB ? -1 : 1; + } + let nodeC = getClosestAncestorIn(nodeB, nodeA, true); + if (nodeC) { + // Case 2: node C (container B or an ancestor) is a child node of A + return offsetA <= getNodeIndex(nodeC) ? -1 : 1; + } + nodeC = getClosestAncestorIn(nodeA, nodeB, true); + if (nodeC) { + // Case 3: node C (container A or an ancestor) is a child node of B + return getNodeIndex(nodeC) < offsetB ? -1 : 1; + } + const root = getCommonAncestor(nodeA, nodeB); + if (!root) { + throw new Error('comparePoints error: nodes have no common ancestor'); + } + + // Case 4: containers are siblings or descendants of siblings + const childA = (nodeA === root) ? root : getClosestAncestorIn(nodeA, root, true); + const childB = (nodeB === root) ? root : getClosestAncestorIn(nodeB, root, true); + + if (childA === childB) { + // This shouldn't be possible + throw module.createError('comparePoints got to case 4 and childA and childB are the same!'); + } + n = root.firstChild; + while (n) { + if (n === childA) { + return -1; + } else if (n === childB) { + return 1; + } + n = n.nextSibling; + } + return 0; + } + + const save = () => { + let result; + if (this.hasFocus()) { + let { selectionStart } = this; + let { selectionEnd } = this; + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const selectionRange = selection.getRangeAt(0); + let node = selectionRange.startContainer; + // eslint-disable-next-line no-bitwise + if ((contentElt.compareDocumentPosition(node) + & window.Node.DOCUMENT_POSITION_CONTAINED_BY) + || contentElt === node + ) { + let offset = selectionRange.startOffset; + if (node.firstChild && offset > 0) { + node = node.childNodes[offset - 1]; + offset = node.textContent.length; + } + let container = node; + while (node !== contentElt) { + node = node.previousSibling; + while (node) { + offset += (node.textContent || '').length; + node = node.previousSibling; + } + node = container.parentNode; + container = node; + } + let selectionText = `${selectionRange}`; + // Fix end of line when only br is selected + const brElt = selectionRange.endContainer.firstChild; + if (brElt && brElt.tagName === 'BR' && selectionRange.endOffset === 1) { + selectionText += '\n'; + } + if (comparePoints( + selection.anchorNode, + selection.anchorOffset, + selection.focusNode, + selection.focusOffset, + ) === 1) { + selectionStart = offset + selectionText.length; + selectionEnd = offset; + } else { + selectionStart = offset; + selectionEnd = offset + selectionText.length; + } + + if (selectionStart === selectionEnd && selectionStart === editor.getContent().length) { + // If cursor is after the trailingNode + selectionEnd -= 1; + selectionStart = selectionEnd; + result = this.setSelectionStartEnd(selectionStart, selectionEnd); + } else { + setSelection(selectionStart, selectionEnd); + result = checkSelection(selectionRange); + // selectionRange doesn't change when selection is at the start of a section + result = result || lastSelectionStart !== this.selectionStart; + } + } + } + } + return result; + }; + + const saveCheckChange = () => save() && ( + lastSelectionStart !== this.selectionStart || lastSelectionEnd !== this.selectionEnd); + + let nextTickAdjustScroll = false; + const longerDebouncedSave = debounce(() => { + this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll); + nextTickAdjustScroll = false; + }, 10); + const debouncedSave = debounce(() => { + this.updateCursorCoordinates(saveCheckChange() && nextTickAdjustScroll); + // In some cases we have to wait a little longer to see the + // selection change (Cmd+A on Chrome OSX) + longerDebouncedSave(); + }); + + return (debounced, adjustScrollParam, forceAdjustScroll) => { + if (forceAdjustScroll) { + lastSelectionStart = undefined; + lastSelectionEnd = undefined; + } + if (debounced) { + nextTickAdjustScroll = nextTickAdjustScroll || adjustScrollParam; + debouncedSave(); + } else { + save(); + } + }; + })(); + + this.getSelectedText = () => { + const min = Math.min(this.selectionStart, this.selectionEnd); + const max = Math.max(this.selectionStart, this.selectionEnd); + return editor.getContent().substring(min, max); + }; + + this.getCoordinates = (inputOffset, containerParam, offsetInContainerParam) => { + let container = containerParam; + let offsetInContainer = offsetInContainerParam; + if (!container) { + const offset = this.findContainer(inputOffset); + ({ container } = offset); + ({ offsetInContainer } = offset); + } + let containerElt = container; + if (!containerElt.hasChildNodes() && container.parentNode) { + containerElt = container.parentNode; + } + let isInvisible = false; + while (!containerElt.offsetHeight) { + isInvisible = true; + if (containerElt.previousSibling) { + containerElt = containerElt.previousSibling; + } else { + containerElt = containerElt.parentNode; + if (!containerElt) { + return { + top: 0, + height: 0, + left: 0, + }; + } + } + } + let rect; + let left = 'left'; + if (isInvisible || container.textContent === '\n') { + rect = containerElt.getBoundingClientRect(); + } else { + const selectedChar = editor.getContent()[inputOffset]; + let startOffset = { + container, + offsetInContainer, + }; + let endOffset = { + container, + offsetInContainer, + }; + if (inputOffset > 0 && (selectedChar === undefined || selectedChar === '\n')) { + left = 'right'; + if (startOffset.offsetInContainer === 0) { + // Need to calculate offset-1 + startOffset = inputOffset - 1; + } else { + startOffset.offsetInContainer -= 1; + } + } else if (endOffset.offsetInContainer === container.textContent.length) { + // Need to calculate offset+1 + endOffset = inputOffset + 1; + } else { + endOffset.offsetInContainer += 1; + } + const range = this.createRange(startOffset, endOffset); + rect = range.getBoundingClientRect(); + } + const contentRect = contentElt.getBoundingClientRect(); + return { + top: Math.round((rect.top - contentRect.top) + contentElt.scrollTop), + height: Math.round(rect.height), + left: Math.round((rect[left] - contentRect.left) + contentElt.scrollLeft), + }; + }; + + this.getClosestWordOffset = (offset) => { + let offsetStart = 0; + let offsetEnd = 0; + let nextOffset = 0; + editor.getContent().split(/\s/).cl_some((word) => { + if (word) { + offsetStart = nextOffset; + offsetEnd = nextOffset + word.length; + if (offsetEnd > offset) { + return true; + } + } + nextOffset += word.length + 1; + return false; + }); + return { + start: offsetStart, + end: offsetEnd, + }; + }; +} + +cledit.SelectionMgr = SelectionMgr; diff --git a/src/services/editor/cledit/cleditUndoMgr.js b/src/services/editor/cledit/cleditUndoMgr.js new file mode 100644 index 0000000..70adf6e --- /dev/null +++ b/src/services/editor/cledit/cleditUndoMgr.js @@ -0,0 +1,176 @@ +import DiffMatchPatch from 'diff-match-patch'; +import cledit from './cleditCore'; + +function UndoMgr(editor) { + cledit.Utils.createEventHooks(this); + + /* eslint-disable new-cap */ + const diffMatchPatch = new DiffMatchPatch(); + /* eslint-enable new-cap */ + + const self = this; + let selectionMgr; + const undoStack = []; + const redoStack = []; + let currentState; + let previousPatches = []; + let currentPatches = []; + const { debounce } = cledit.Utils; + + this.options = { + undoStackMaxSize: 200, + bufferStateUntilIdle: 1000, + patchHandler: { + makePatches(oldContent, newContent, diffs) { + return diffMatchPatch.patch_make(oldContent, diffs); + }, + applyPatches(patches, content) { + return diffMatchPatch.patch_apply(patches, content)[0]; + }, + reversePatches(patches) { + const reversedPatches = diffMatchPatch.patch_deepCopy(patches).reverse(); + reversedPatches.cl_each((patch) => { + patch.diffs.cl_each((diff) => { + diff[0] = -diff[0]; + }); + }); + return reversedPatches; + }, + }, + }; + + let stateMgr; + function StateMgr() { + let currentTime; + let lastTime; + let lastMode; + + this.isBufferState = () => { + currentTime = Date.now(); + return this.currentMode !== 'single' && + this.currentMode === lastMode && + currentTime - lastTime < self.options.bufferStateUntilIdle; + }; + + this.setDefaultMode = (mode) => { + this.currentMode = this.currentMode || mode; + }; + + this.resetMode = () => { + stateMgr.currentMode = undefined; + lastMode = undefined; + }; + + this.saveMode = () => { + lastMode = this.currentMode; + this.currentMode = undefined; + lastTime = currentTime; + }; + } + + class State { + addToUndoStack() { + undoStack.push(this); + this.patches = previousPatches; + previousPatches = []; + } + addToRedoStack() { + redoStack.push(this); + this.patches = previousPatches; + previousPatches = []; + } + } + + stateMgr = new StateMgr(); + this.setCurrentMode = (mode) => { + stateMgr.currentMode = mode; + }; + this.setDefaultMode = stateMgr.setDefaultMode.cl_bind(stateMgr); + + this.addDiffs = (oldContent, newContent, diffs) => { + const patches = this.options.patchHandler.makePatches(oldContent, newContent, diffs); + patches.cl_each(patch => currentPatches.push(patch)); + }; + + function saveCurrentPatches() { + // Move currentPatches into previousPatches + Array.prototype.push.apply(previousPatches, currentPatches); + currentPatches = []; + } + + this.saveState = debounce(() => { + redoStack.length = 0; + if (!stateMgr.isBufferState()) { + currentState.addToUndoStack(); + + // Limit the size of the stack + while (undoStack.length > this.options.undoStackMaxSize) { + undoStack.shift(); + } + } + saveCurrentPatches(); + currentState = new State(); + stateMgr.saveMode(); + this.$trigger('undoStateChange'); + }); + + this.canUndo = () => !!undoStack.length; + this.canRedo = () => !!redoStack.length; + + const restoreState = (patchesParam, isForward) => { + let patches = patchesParam; + // Update editor + const content = editor.getContent(); + if (!isForward) { + patches = this.options.patchHandler.reversePatches(patches); + } + + const newContent = this.options.patchHandler.applyPatches(patches, content); + const newContentText = newContent.text || newContent; + const range = editor.setContent(newContentText, true); + const selection = newContent.selection || { + start: range.end, + end: range.end, + }; + + selectionMgr.setSelectionStartEnd(selection.start, selection.end); + selectionMgr.updateCursorCoordinates(true); + + stateMgr.resetMode(); + this.$trigger('undoStateChange'); + editor.adjustCursorPosition(); + }; + + this.undo = () => { + const state = undoStack.pop(); + if (!state) { + return; + } + saveCurrentPatches(); + currentState.addToRedoStack(); + restoreState(currentState.patches); + previousPatches = state.patches; + currentState = state; + }; + + this.redo = () => { + const state = redoStack.pop(); + if (!state) { + return; + } + currentState.addToUndoStack(); + restoreState(state.patches, true); + previousPatches = state.patches; + currentState = state; + }; + + this.init = (options) => { + this.options.cl_extend(options || {}); + ({ selectionMgr } = editor); + if (!currentState) { + currentState = new State(); + } + }; +} + +cledit.UndoMgr = UndoMgr; diff --git a/src/services/editor/cledit/cleditUtils.js b/src/services/editor/cledit/cleditUtils.js new file mode 100644 index 0000000..bbb365d --- /dev/null +++ b/src/services/editor/cledit/cleditUtils.js @@ -0,0 +1,129 @@ +import cledit from './cleditCore'; + +const Utils = { + isGecko: 'MozAppearance' in document.documentElement.style, + isWebkit: 'WebkitAppearance' in document.documentElement.style, + isMsie: 'msTransform' in document.documentElement.style, + isMac: navigator.userAgent.indexOf('Mac OS X') !== -1, +}; + +// Faster than setTimeout(0). Credit: https://github.com/stefanpenner/es6-promise +Utils.defer = (() => { + const queue = new Array(1000); + let queueLength = 0; + function flush() { + for (let i = 0; i < queueLength; i += 1) { + try { + queue[i](); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e.message, e.stack); + } + queue[i] = undefined; + } + queueLength = 0; + } + + let iterations = 0; + const observer = new window.MutationObserver(flush); + const node = document.createTextNode(''); + observer.observe(node, { characterData: true }); + + return (fn) => { + queue[queueLength] = fn; + queueLength += 1; + if (queueLength === 1) { + iterations = (iterations + 1) % 2; + node.data = iterations; + } + }; +})(); + +Utils.debounce = (func, wait) => { + let timeoutId; + let isExpected; + return wait + ? () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(func, wait); + } + : () => { + if (!isExpected) { + isExpected = true; + Utils.defer(() => { + isExpected = false; + func(); + }); + } + }; +}; + +Utils.createEventHooks = (object) => { + const listenerMap = Object.create(null); + object.$trigger = (eventType, ...args) => { + const listeners = listenerMap[eventType]; + if (listeners) { + listeners.cl_each((listener) => { + try { + listener.apply(object, args); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e.message, e.stack); + } + }); + } + }; + object.on = (eventType, listener) => { + let listeners = listenerMap[eventType]; + if (!listeners) { + listeners = []; + listenerMap[eventType] = listeners; + } + listeners.push(listener); + }; + object.off = (eventType, listener) => { + const listeners = listenerMap[eventType]; + if (listeners) { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + } + }; +}; + +Utils.findContainer = (elt, offset) => { + let containerOffset = 0; + let container; + let child = elt; + do { + container = child; + child = child.firstChild; + if (child) { + do { + const len = child.textContent.length; + if (containerOffset <= offset && containerOffset + len > offset) { + break; + } + containerOffset += len; + child = child.nextSibling; + } while (child); + } + } while (child && child.firstChild && child.nodeType !== 3); + + if (child) { + return { + container: child, + offsetInContainer: offset - containerOffset, + }; + } + while (container.lastChild) { + container = container.lastChild; + } + return { + container, + offsetInContainer: container.nodeType === 3 ? container.textContent.length : 0, + }; +}; + +cledit.Utils = Utils; diff --git a/src/services/editor/cledit/cleditWatcher.js b/src/services/editor/cledit/cleditWatcher.js new file mode 100644 index 0000000..a1ec617 --- /dev/null +++ b/src/services/editor/cledit/cleditWatcher.js @@ -0,0 +1,34 @@ +import cledit from './cleditCore'; + +function Watcher(editor, listener) { + this.isWatching = false; + let contentObserver; + this.startWatching = () => { + this.stopWatching(); + this.isWatching = true; + contentObserver = new window.MutationObserver(listener); + contentObserver.observe(editor.$contentElt, { + childList: true, + subtree: true, + characterData: true, + }); + }; + this.stopWatching = () => { + if (contentObserver) { + contentObserver.disconnect(); + contentObserver = undefined; + } + this.isWatching = false; + }; + this.noWatch = (cb) => { + if (this.isWatching === true) { + this.stopWatching(); + cb(); + this.startWatching(); + } else { + cb(); + } + }; +} + +cledit.Watcher = Watcher; diff --git a/src/services/editor/cledit/index.js b/src/services/editor/cledit/index.js new file mode 100644 index 0000000..ba5167b --- /dev/null +++ b/src/services/editor/cledit/index.js @@ -0,0 +1,11 @@ +import '../../../libs/clunderscore'; +import cledit from './cleditCore'; +import './cleditHighlighter'; +import './cleditKeystroke'; +import './cleditMarker'; +import './cleditSelectionMgr'; +import './cleditUndoMgr'; +import './cleditUtils'; +import './cleditWatcher'; + +export default cledit; diff --git a/src/services/editor/editorSvcDiscussions.js b/src/services/editor/editorSvcDiscussions.js new file mode 100644 index 0000000..3805e1e --- /dev/null +++ b/src/services/editor/editorSvcDiscussions.js @@ -0,0 +1,256 @@ +import DiffMatchPatch from 'diff-match-patch'; +import cledit from './cledit'; +import utils from '../utils'; +import diffUtils from '../diffUtils'; +import store from '../../store'; +import EditorClassApplier from '../../components/common/EditorClassApplier'; +import PreviewClassApplier from '../../components/common/PreviewClassApplier'; + +let clEditor; +// let discussionIds = {}; +let discussionMarkers = {}; +let markerKeys; +let markerIdxMap; +let previousPatchableText; +let currentPatchableText; +let isChangePatch; +let contentId; +let editorClassAppliers = {}; +let previewClassAppliers = {}; + +function getDiscussionMarkers(discussion, discussionId, onMarker) { + const getMarker = (offsetName) => { + const markerKey = `${discussionId}:${offsetName}`; + let marker = discussionMarkers[markerKey]; + if (!marker) { + marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); + marker.discussionId = discussionId; + marker.offsetName = offsetName; + clEditor.addMarker(marker); + discussionMarkers[markerKey] = marker; + } + onMarker(marker); + }; + getMarker('start'); + getMarker('end'); +} + +function syncDiscussionMarkers(content, writeOffsets) { + const discussions = { + ...content.discussions, + }; + const newDiscussion = store.getters['discussion/newDiscussion']; + if (newDiscussion) { + discussions[store.state.discussion.newDiscussionId] = { + ...newDiscussion, + }; + } + Object.entries(discussionMarkers).forEach(([markerKey, marker]) => { + // Remove marker if discussion was removed + const discussion = discussions[marker.discussionId]; + if (!discussion) { + clEditor.removeMarker(marker); + delete discussionMarkers[markerKey]; + } + }); + + Object.entries(discussions).forEach(([discussionId, discussion]) => { + getDiscussionMarkers(discussion, discussionId, writeOffsets + ? (marker) => { + discussion[marker.offsetName] = marker.offset; + } + : (marker) => { + marker.offset = discussion[marker.offsetName]; + }); + }); + + if (writeOffsets && newDiscussion) { + store.commit( + 'discussion/patchNewDiscussion', + discussions[store.state.discussion.newDiscussionId], + ); + } +} + +function removeDiscussionMarkers() { + Object.entries(discussionMarkers).forEach(([, marker]) => { + clEditor.removeMarker(marker); + }); + discussionMarkers = {}; + markerKeys = []; + markerIdxMap = Object.create(null); +} + +const diffMatchPatch = new DiffMatchPatch(); + +function makePatches() { + const diffs = diffMatchPatch.diff_main(previousPatchableText, currentPatchableText); + return diffMatchPatch.patch_make(previousPatchableText, diffs); +} + +function applyPatches(patches) { + const newPatchableText = diffMatchPatch.patch_apply(patches, currentPatchableText)[0]; + let result = newPatchableText; + if (markerKeys.length) { + // Strip text markers + result = result.replace(new RegExp(`[\ue000-${String.fromCharCode((0xe000 + markerKeys.length) - 1)}]`, 'g'), ''); + } + // Expect a `contentChanged` event + if (result !== clEditor.getContent()) { + previousPatchableText = currentPatchableText; + currentPatchableText = newPatchableText; + isChangePatch = true; + } + return result; +} + +function reversePatches(patches) { + const result = diffMatchPatch.patch_deepCopy(patches).reverse(); + result.forEach((patch) => { + patch.diffs.forEach((diff) => { + diff[0] = -diff[0]; + }); + }); + return result; +} + +export default { + createClEditor(editorElt) { + this.clEditor = cledit(editorElt, editorElt.parentNode, true); + ({ clEditor } = this); + clEditor.on('contentChanged', (text) => { + const oldContent = store.getters['content/current']; + const newContent = { + ...utils.deepCopy(oldContent), + text: utils.sanitizeText(text), + }; + syncDiscussionMarkers(newContent, true); + if (!isChangePatch) { + previousPatchableText = currentPatchableText; + currentPatchableText = diffUtils.makePatchableText(newContent, markerKeys, markerIdxMap); + } else { + // Take a chance to restore discussion offsets on undo/redo + newContent.text = currentPatchableText; + diffUtils.restoreDiscussionOffsets(newContent, markerKeys); + syncDiscussionMarkers(newContent, false); + } + store.dispatch('content/patchCurrent', newContent); + isChangePatch = false; + }); + clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false)); + }, + initClEditorInternal(opts) { + const content = store.getters['content/current']; + if (content) { + removeDiscussionMarkers(); // Markers will be recreated on contentChanged + const contentState = store.getters['contentState/current']; + const options = Object.assign({ + selectionStart: contentState.selectionStart, + selectionEnd: contentState.selectionEnd, + patchHandler: { + makePatches, + applyPatches, + reversePatches, + }, + }, opts); + + if (contentId !== content.id) { + contentId = content.id; + currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap); + previousPatchableText = currentPatchableText; + syncDiscussionMarkers(content, false); + options.content = content.text; + } + + clEditor.init(options); + } + }, + applyContent() { + if (clEditor) { + const content = store.getters['content/current']; + if (clEditor.setContent(content.text, true).range) { + // Marker will be recreated on contentChange + removeDiscussionMarkers(); + } else { + syncDiscussionMarkers(content, false); + } + } + }, + getTrimmedSelection() { + const { selectionMgr } = clEditor; + let start = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd); + let end = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd); + const text = clEditor.getContent(); + while ((text[start] || '').match(/\s/)) { + start += 1; + } + while ((text[end - 1] || '').match(/\s/)) { + end -= 1; + } + return start < end && { start, end }; + }, + initHighlighters() { + store.watch( + () => store.getters['discussion/newDiscussion'], + () => syncDiscussionMarkers(store.getters['content/current'], false), + ); + + store.watch( + () => store.getters['discussion/currentFileDiscussions'], + (discussions) => { + const classGetter = (type, discussionId) => () => { + const classes = [`discussion-${type}-highlighting--${discussionId}`, `discussion-${type}-highlighting`]; + if (store.state.discussion.currentDiscussionId === discussionId) { + classes.push(`discussion-${type}-highlighting--selected`); + } + return classes; + }; + const offsetGetter = discussionId => () => { + const startMarker = discussionMarkers[`${discussionId}:start`]; + const endMarker = discussionMarkers[`${discussionId}:end`]; + return startMarker && endMarker && { + start: startMarker.offset, + end: endMarker.offset, + }; + }; + + // Editor class appliers + const oldEditorClassAppliers = editorClassAppliers; + editorClassAppliers = {}; + Object.keys(discussions).forEach((discussionId) => { + const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier( + classGetter('editor', discussionId), + offsetGetter(discussionId), + { discussionId }, + ); + editorClassAppliers[discussionId] = classApplier; + }); + // Clean unused class appliers + Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => { + if (!editorClassAppliers[discussionId]) { + classApplier.stop(); + } + }); + + // Preview class appliers + const oldPreviewClassAppliers = previewClassAppliers; + previewClassAppliers = {}; + Object.keys(discussions).forEach((discussionId) => { + const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier( + classGetter('preview', discussionId), + offsetGetter(discussionId), + { discussionId }, + ); + previewClassAppliers[discussionId] = classApplier; + }); + // Clean unused class appliers + Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => { + if (!previewClassAppliers[discussionId]) { + classApplier.stop(); + } + }); + }, + ); + }, +}; + diff --git a/src/services/editor/editorSvcUtils.js b/src/services/editor/editorSvcUtils.js new file mode 100644 index 0000000..6edc584 --- /dev/null +++ b/src/services/editor/editorSvcUtils.js @@ -0,0 +1,151 @@ +import DiffMatchPatch from 'diff-match-patch'; +import cledit from './cledit'; +import animationSvc from '../animationSvc'; +import store from '../../store'; + +const diffMatchPatch = new DiffMatchPatch(); + +export default { + /** + * Get an object describing the position of the scroll bar in the file. + */ + getScrollPosition(elt = store.getters['layout/styles'].showEditor + ? this.editorElt : this.previewElt) { + const dimensionKey = elt === this.editorElt + ? 'editorDimension' + : 'previewDimension'; + const { scrollTop } = elt.parentNode; + let result; + if (this.previewCtxMeasured) { + this.previewCtxMeasured.sectionDescList.some((sectionDesc, sectionIdx) => { + if (scrollTop >= sectionDesc[dimensionKey].endOffset) { + return false; + } + const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) / + (sectionDesc[dimensionKey].height || 1); + result = { + sectionIdx, + posInSection, + }; + return true; + }); + } + return result; + }, + + /** + * Restore the scroll position from the current file content state. + */ + restoreScrollPosition() { + const { scrollPosition } = store.getters['contentState/current']; + if (scrollPosition && this.previewCtxMeasured) { + const sectionDesc = this.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx]; + if (sectionDesc) { + const editorScrollTop = sectionDesc.editorDimension.startOffset + + (sectionDesc.editorDimension.height * scrollPosition.posInSection); + this.editorElt.parentNode.scrollTop = Math.floor(editorScrollTop); + const previewScrollTop = sectionDesc.previewDimension.startOffset + + (sectionDesc.previewDimension.height * scrollPosition.posInSection); + this.previewElt.parentNode.scrollTop = Math.floor(previewScrollTop); + } + } + }, + + /** + * Get the offset in the preview corresponding to the offset of the markdown in the editor + */ + getPreviewOffset( + editorOffset, + sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList, + ) { + if (!sectionDescList) { + return null; + } + let offset = editorOffset; + let previewOffset = 0; + sectionDescList.some((sectionDesc) => { + if (!sectionDesc.textToPreviewDiffs) { + previewOffset = null; + return true; + } + if (sectionDesc.section.text.length >= offset) { + previewOffset += diffMatchPatch.diff_xIndex(sectionDesc.textToPreviewDiffs, offset); + return true; + } + offset -= sectionDesc.section.text.length; + previewOffset += sectionDesc.previewText.length; + return false; + }); + return previewOffset; + }, + + /** + * Get the offset of the markdown in the editor corresponding to the offset in the preview + */ + getEditorOffset( + previewOffset, + sectionDescList = (this.previewCtxWithDiffs || {}).sectionDescList, + ) { + if (!sectionDescList) { + return null; + } + let offset = previewOffset; + let editorOffset = 0; + sectionDescList.some((sectionDesc) => { + if (!sectionDesc.textToPreviewDiffs) { + editorOffset = null; + return true; + } + if (sectionDesc.previewText.length >= offset) { + const previewToTextDiffs = sectionDesc.textToPreviewDiffs + .map(diff => [-diff[0], diff[1]]); + editorOffset += diffMatchPatch.diff_xIndex(previewToTextDiffs, offset); + return true; + } + offset -= sectionDesc.previewText.length; + editorOffset += sectionDesc.section.text.length; + return false; + }); + return editorOffset; + }, + + /** + * Get the coordinates of an offset in the preview + */ + getPreviewOffsetCoordinates(offset) { + const start = cledit.Utils.findContainer(this.previewElt, offset && offset - 1); + const end = cledit.Utils.findContainer(this.previewElt, offset || offset + 1); + const range = document.createRange(); + range.setStart(start.container, start.offsetInContainer); + range.setEnd(end.container, end.offsetInContainer); + const rect = range.getBoundingClientRect(); + const contentRect = this.previewElt.getBoundingClientRect(); + return { + top: Math.round((rect.top - contentRect.top) + this.previewElt.scrollTop), + height: Math.round(rect.height), + left: Math.round((rect.right - contentRect.left) + this.previewElt.scrollLeft), + }; + }, + + /** + * Scroll the preview (or the editor if preview is hidden) to the specified anchor + */ + scrollToAnchor(anchor) { + let scrollTop = 0; + const scrollerElt = this.previewElt.parentNode; + const elt = document.getElementById(anchor); + if (elt) { + scrollTop = elt.offsetTop; + } + const maxScrollTop = scrollerElt.scrollHeight - scrollerElt.offsetHeight; + if (scrollTop < 0) { + scrollTop = 0; + } else if (scrollTop > maxScrollTop) { + scrollTop = maxScrollTop; + } + animationSvc.animate(scrollerElt) + .scrollTop(scrollTop) + .duration(360) + .start(); + }, +}; diff --git a/src/services/editor/sectionUtils.js b/src/services/editor/sectionUtils.js new file mode 100644 index 0000000..86fe120 --- /dev/null +++ b/src/services/editor/sectionUtils.js @@ -0,0 +1,114 @@ +class SectionDimension { + constructor(startOffset, endOffset) { + this.startOffset = startOffset; + this.endOffset = endOffset; + this.height = endOffset - startOffset; + } +} + +const dimensionNormalizer = dimensionName => (editorSvc) => { + const dimensionList = editorSvc.previewCtx.sectionDescList + .map(sectionDesc => sectionDesc[dimensionName]); + let dimension; + let i; + let j; + for (i = 0; i < dimensionList.length; i += 1) { + dimension = dimensionList[i]; + if (dimension.height) { + for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) { + // Loop + } + const normalizeFactor = j - i; + if (normalizeFactor !== 1) { + const normalizedHeight = dimension.height / normalizeFactor; + dimension.height = normalizedHeight; + dimension.endOffset = dimension.startOffset + dimension.height; + for (j = i + 1; j < i + normalizeFactor; j += 1) { + const startOffset = dimension.endOffset; + dimension = dimensionList[j]; + dimension.startOffset = startOffset; + dimension.height = normalizedHeight; + dimension.endOffset = dimension.startOffset + dimension.height; + } + i = j - 1; + } + } + } +}; + +const normalizeEditorDimensions = dimensionNormalizer('editorDimension'); +const normalizePreviewDimensions = dimensionNormalizer('previewDimension'); +const normalizeTocDimensions = dimensionNormalizer('tocDimension'); + +export default { + measureSectionDimensions(editorSvc) { + let editorSectionOffset = 0; + let previewSectionOffset = 0; + let tocSectionOffset = 0; + let sectionDesc = editorSvc.previewCtx.sectionDescList[0]; + let nextSectionDesc; + let i = 1; + for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) { + nextSectionDesc = editorSvc.previewCtx.sectionDescList[i]; + + // Measure editor section + let newEditorSectionOffset = nextSectionDesc.editorElt + ? nextSectionDesc.editorElt.offsetTop + : editorSectionOffset; + newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset + ? newEditorSectionOffset + : editorSectionOffset; + sectionDesc.editorDimension = new SectionDimension( + editorSectionOffset, + newEditorSectionOffset, + ); + editorSectionOffset = newEditorSectionOffset; + + // Measure preview section + let newPreviewSectionOffset = nextSectionDesc.previewElt + ? nextSectionDesc.previewElt.offsetTop + : previewSectionOffset; + newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset + ? newPreviewSectionOffset + : previewSectionOffset; + sectionDesc.previewDimension = new SectionDimension( + previewSectionOffset, + newPreviewSectionOffset, + ); + previewSectionOffset = newPreviewSectionOffset; + + // Measure TOC section + let newTocSectionOffset = nextSectionDesc.tocElt + ? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2) + : tocSectionOffset; + newTocSectionOffset = newTocSectionOffset > tocSectionOffset + ? newTocSectionOffset + : tocSectionOffset; + sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset); + tocSectionOffset = newTocSectionOffset; + + sectionDesc = nextSectionDesc; + } + + // Last section + sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1]; + if (sectionDesc) { + sectionDesc.editorDimension = new SectionDimension( + editorSectionOffset, + editorSvc.editorElt.scrollHeight, + ); + sectionDesc.previewDimension = new SectionDimension( + previewSectionOffset, + editorSvc.previewElt.scrollHeight, + ); + sectionDesc.tocDimension = new SectionDimension( + tocSectionOffset, + editorSvc.tocElt.scrollHeight, + ); + } + + normalizeEditorDimensions(editorSvc); + normalizePreviewDimensions(editorSvc); + normalizeTocDimensions(editorSvc); + }, +}; diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js new file mode 100644 index 0000000..d59d0cf --- /dev/null +++ b/src/services/editorSvc.js @@ -0,0 +1,685 @@ +import Vue from 'vue'; +import DiffMatchPatch from 'diff-match-patch'; +import Prism from 'prismjs'; +import markdownItPandocRenderer from 'markdown-it-pandoc-renderer'; +import md5 from 'js-md5'; +import cledit from './editor/cledit'; +import pagedown from '../libs/pagedown'; +import htmlSanitizer from '../libs/htmlSanitizer'; +import markdownConversionSvc from './markdownConversionSvc'; +import markdownGrammarSvc from './markdownGrammarSvc'; +import sectionUtils from './editor/sectionUtils'; +import extensionSvc from './extensionSvc'; +import editorSvcDiscussions from './editor/editorSvcDiscussions'; +import editorSvcUtils from './editor/editorSvcUtils'; +import utils from './utils'; +import store from '../store'; +import syncSvc from './syncSvc'; +import constants from '../data/constants'; +import localDbSvc from './localDbSvc'; + +const allowDebounce = (action, wait) => { + let timeoutId; + return (doDebounce = false, ...params) => { + clearTimeout(timeoutId); + if (doDebounce) { + timeoutId = setTimeout(() => action(...params), wait); + } else { + action(...params); + } + }; +}; + +const diffMatchPatch = new DiffMatchPatch(); +let instantPreview = true; +let tokens; + +class SectionDesc { + constructor(section, previewElt, tocElt, html) { + this.section = section; + this.editorElt = section.elt; + this.previewElt = previewElt; + this.tocElt = tocElt; + this.html = html; + } +} + +const pathUrlMap = Object.create(null); + +const getImgUrl = async (uri) => { + if (uri.indexOf('http://') !== 0 && uri.indexOf('https://') !== 0) { + const currDirNode = store.getters['explorer/selectedNodeFolder']; + const absoluteImgPath = utils.getAbsoluteFilePath(currDirNode, uri); + if (pathUrlMap[absoluteImgPath]) { + return pathUrlMap[absoluteImgPath]; + } + const md5Id = md5(absoluteImgPath); + let imgItem = await localDbSvc.getImgItem(md5Id); + if (!imgItem) { + await syncSvc.syncImg(absoluteImgPath); + imgItem = await localDbSvc.getImgItem(md5Id); + } + if (imgItem) { + // imgItem 如果不存在 则加载 TODO + const imgFile = utils.base64ToBlob(imgItem.content, uri); + const url = URL.createObjectURL(imgFile); + pathUrlMap[absoluteImgPath] = url; + return url; + } + return ''; + } + return uri; +}; + +// Use a vue instance as an event bus +const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, { + // Elements + editorElt: null, + previewElt: null, + tocElt: null, + // Other objects + clEditor: null, + pagedownEditor: null, + options: null, + prismGrammars: null, + converter: null, + parsingCtx: null, + conversionCtx: null, + previewCtx: { + sectionDescList: [], + }, + previewCtxMeasured: null, + previewCtxWithDiffs: null, + sectionList: null, + selectionRange: null, + previewSelectionRange: null, + previewSelectionStartOffset: null, + + /** + * Initialize the Prism grammar with the options + */ + initPrism() { + const options = { + ...this.options, + insideFences: markdownConversionSvc.defaultOptions.insideFences, + }; + this.prismGrammars = markdownGrammarSvc.makeGrammars(options); + }, + + /** + * Initialize the markdown-it converter with the options + */ + initConverter() { + this.converter = markdownConversionSvc.createConverter(this.options, true); + }, + + /** + * Initialize the cledit editor with markdown-it section parser and Prism highlighter + */ + initClEditor() { + this.previewCtxMeasured = null; + editorSvc.$emit('previewCtxMeasured', null); + this.previewCtxWithDiffs = null; + editorSvc.$emit('previewCtxWithDiffs', null); + const options = { + sectionHighlighter: section => Prism + .highlight(section.text, this.prismGrammars[section.data]), + sectionParser: (text) => { + this.parsingCtx = markdownConversionSvc.parseSections(this.converter, text); + return this.parsingCtx.sections; + }, + getCursorFocusRatio: () => { + if (store.getters['data/layoutSettings'].focusMode) { + return 1; + } + return 0.15; + }, + }; + this.initClEditorInternal(options); + this.restoreScrollPosition(); + }, + + /** + * Finish the conversion initiated by the section parser + */ + convert() { + this.conversionCtx = markdownConversionSvc.convert(this.parsingCtx, this.conversionCtx); + this.$emit('conversionCtx', this.conversionCtx); + ({ tokens } = this.parsingCtx.markdownState); + }, + + /** + * Refresh the preview with the result of `convert()` + */ + async refreshPreview() { + const sectionDescList = []; + let sectionPreviewElt; + let sectionTocElt; + let sectionIdx = 0; + let sectionDescIdx = 0; + let insertBeforePreviewElt = this.previewElt.firstChild; + let insertBeforeTocElt = this.tocElt.firstChild; + let previewHtml = ''; + let loadingImages = []; + this.conversionCtx.htmlSectionDiff.forEach((item) => { + for (let i = 0; i < item[1].length; i += 1) { + const section = this.conversionCtx.sectionList[sectionIdx]; + if (item[0] === 0) { + let sectionDesc = this.previewCtx.sectionDescList[sectionDescIdx]; + sectionDescIdx += 1; + if (sectionDesc.editorElt !== section.elt) { + // Force textToPreviewDiffs computation + sectionDesc = new SectionDesc( + section, + sectionDesc.previewElt, + sectionDesc.tocElt, + sectionDesc.html, + ); + } + sectionDescList.push(sectionDesc); + previewHtml += sectionDesc.html; + sectionIdx += 1; + insertBeforePreviewElt = insertBeforePreviewElt.nextSibling; + insertBeforeTocElt = insertBeforeTocElt.nextSibling; + } else if (item[0] === -1) { + sectionDescIdx += 1; + sectionPreviewElt = insertBeforePreviewElt; + insertBeforePreviewElt = insertBeforePreviewElt.nextSibling; + this.previewElt.removeChild(sectionPreviewElt); + sectionTocElt = insertBeforeTocElt; + insertBeforeTocElt = insertBeforeTocElt.nextSibling; + this.tocElt.removeChild(sectionTocElt); + } else if (item[0] === 1) { + const html = htmlSanitizer.sanitizeHtml(this.conversionCtx.htmlSectionList[sectionIdx]); + sectionIdx += 1; + + // Create preview section element + sectionPreviewElt = document.createElement('div'); + sectionPreviewElt.className = 'cl-preview-section'; + sectionPreviewElt.innerHTML = html; + if (insertBeforePreviewElt) { + this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt); + } else { + this.previewElt.appendChild(sectionPreviewElt); + } + extensionSvc.sectionPreview(sectionPreviewElt, this.options, true); + const imgs = Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('img')).map((imgElt) => { + if (imgElt.src.indexOf(constants.origin) >= 0) { + const uri = decodeURIComponent(imgElt.attributes.src.nodeValue); + imgElt.removeAttribute('src'); + return { imgElt, uri }; + } + return { imgElt }; + }); + loadingImages = [ + ...loadingImages, + ...imgs, + ]; + + Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('a')).forEach((aElt) => { + const url = aElt.attributes && aElt.attributes.href && aElt.attributes.href.nodeValue; + if (!url || url.indexOf('http://') >= 0 || url.indexOf('https://') >= 0 || url.indexOf('#') >= 0) { + return; + } + aElt.href = 'javascript:void(0);'; // eslint-disable-line no-script-url + aElt.setAttribute('onclick', `window.viewFileByPath('${utils.decodeUrlPath(url)}')`); + }); + + // Create TOC section element + sectionTocElt = document.createElement('div'); + sectionTocElt.className = 'cl-toc-section'; + const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6'); + if (headingElt) { + const clonedElt = headingElt.cloneNode(true); + clonedElt.removeAttribute('id'); + sectionTocElt.appendChild(clonedElt); + // 创建一个新的 元素 + const contentElt = document.createElement('span'); + contentElt.className = 'content'; + // 将原始内容移动到新的 元素中 + while (headingElt.firstChild) { + contentElt.appendChild(headingElt.firstChild); + } + const prefixElt = document.createElement('span'); + prefixElt.className = 'prefix'; + headingElt.insertBefore(prefixElt, headingElt.firstChild); + // 将新的 元素替换原始元素 + headingElt.appendChild(contentElt); + const suffixElt = document.createElement('span'); + suffixElt.className = 'suffix'; + headingElt.appendChild(suffixElt); + } + if (insertBeforeTocElt) { + this.tocElt.insertBefore(sectionTocElt, insertBeforeTocElt); + } else { + this.tocElt.appendChild(sectionTocElt); + } + + previewHtml += html; + sectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html)); + } + } + }); + + this.tocElt.classList[ + this.tocElt.querySelector('.cl-toc-section *') ? 'remove' : 'add' + ]('toc-tab--empty'); + + this.previewCtx = { + markdown: this.conversionCtx.text, + html: previewHtml.replace(/^\s+|\s+$/g, ''), + text: this.previewElt.textContent, + sectionDescList, + }; + this.$emit('previewCtx', this.previewCtx); + this.makeTextToPreviewDiffs(); + + // Wait for images to load + const loadedPromises = loadingImages.map(it => new Promise((resolve, reject) => { + if (!it.imgElt.src && it.uri) { + getImgUrl(it.uri).then((newUrl) => { + it.imgElt.src = newUrl; + resolve(); + }, () => reject(new Error('加载当前空间图片出错'))); + return; + } + if (!it.imgElt.src) { + resolve(); + return; + } + const img = new window.Image(); + img.onload = resolve; + img.onerror = resolve; + img.src = it.imgElt.src; + })); + await Promise.all(loadedPromises); + + // Debounce if sections have already been measured + this.measureSectionDimensions(!!this.previewCtxMeasured); + }, + + /** + * Measure the height of each section in editor, preview and toc. + */ + measureSectionDimensions: allowDebounce((restoreScrollPosition = false, force = false) => { + if (force || editorSvc.previewCtx !== editorSvc.previewCtxMeasured) { + sectionUtils.measureSectionDimensions(editorSvc); + editorSvc.previewCtxMeasured = editorSvc.previewCtx; + if (restoreScrollPosition) { + editorSvc.restoreScrollPosition(); + } + editorSvc.$emit('previewCtxMeasured', editorSvc.previewCtxMeasured); + } + }, 500), + + /** + * Compute the diffs between editor's markdown and preview's html + * asynchronously unless there is only one section to compute. + */ + makeTextToPreviewDiffs() { + if (editorSvc.previewCtx !== editorSvc.previewCtxWithDiffs) { + const makeOne = () => { + let hasOne = false; + const hasMore = editorSvc.previewCtx.sectionDescList + .some((sectionDesc) => { + if (!sectionDesc.textToPreviewDiffs) { + if (hasOne) { + return true; + } + if (!sectionDesc.previewText) { + sectionDesc.previewText = sectionDesc.previewElt.textContent; + } + sectionDesc.textToPreviewDiffs = diffMatchPatch.diff_main( + sectionDesc.section.text, + sectionDesc.previewText, + ); + hasOne = true; + } + return false; + }); + if (hasMore) { + setTimeout(() => makeOne(), 10); + } else { + editorSvc.previewCtxWithDiffs = editorSvc.previewCtx; + editorSvc.$emit('previewCtxWithDiffs', editorSvc.previewCtxWithDiffs); + } + }; + makeOne(); + } + }, + + /** + * Save editor selection/scroll state into the store. + */ + saveContentState: allowDebounce(() => { + const scrollPosition = editorSvc.getScrollPosition() || + store.getters['contentState/current'].scrollPosition; + store.dispatch('contentState/patchCurrent', { + selectionStart: editorSvc.clEditor.selectionMgr.selectionStart, + selectionEnd: editorSvc.clEditor.selectionMgr.selectionEnd, + scrollPosition, + }); + }, 100), + + /** + * Report selection from the preview to the editor. + */ + saveSelection: allowDebounce(() => { + const selection = window.getSelection(); + let range = selection.rangeCount && selection.getRangeAt(0); + if (range) { + if ( + /* eslint-disable no-bitwise */ + !(editorSvc.previewElt.compareDocumentPosition(range.startContainer) & + window.Node.DOCUMENT_POSITION_CONTAINED_BY) || + !(editorSvc.previewElt.compareDocumentPosition(range.endContainer) & + window.Node.DOCUMENT_POSITION_CONTAINED_BY) + /* eslint-enable no-bitwise */ + ) { + range = null; + } + } + if (editorSvc.previewSelectionRange !== range) { + let previewSelectionStartOffset; + let previewSelectionEndOffset; + if (range) { + const startRange = document.createRange(); + startRange.setStart(editorSvc.previewElt, 0); + startRange.setEnd(range.startContainer, range.startOffset); + previewSelectionStartOffset = `${startRange}`.length; + previewSelectionEndOffset = previewSelectionStartOffset + `${range}`.length; + const editorStartOffset = editorSvc.getEditorOffset(previewSelectionStartOffset); + const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset); + if (editorStartOffset != null && editorEndOffset != null) { + editorSvc.clEditor.selectionMgr.setSelectionStartEnd( + editorStartOffset, + editorEndOffset, + ); + } + } + editorSvc.previewSelectionRange = range; + editorSvc.$emit('previewSelectionRange', editorSvc.previewSelectionRange); + } + }, 50), + + /** + * Returns the pandoc AST generated from the file tokens and the converter options + */ + getPandocAst() { + return tokens && markdownItPandocRenderer(tokens, this.converter.options); + }, + + /** + * Pass the elements to the store and initialize the editor. + */ + init(editorElt, previewElt, tocElt) { + this.editorElt = editorElt; + this.previewElt = previewElt; + this.tocElt = tocElt; + + this.createClEditor(editorElt); + + this.clEditor.on('contentChanged', (content, diffs, sectionList) => { + this.parsingCtx = { + ...this.parsingCtx, + sectionList, + }; + }); + this.clEditor.undoMgr.on('undoStateChange', () => { + const canUndo = this.clEditor.undoMgr.canUndo(); + if (canUndo !== store.state.layout.canUndo) { + store.commit('layout/setCanUndo', canUndo); + } + const canRedo = this.clEditor.undoMgr.canRedo(); + if (canRedo !== store.state.layout.canRedo) { + store.commit('layout/setCanRedo', canRedo); + } + }); + this.pagedownEditor = pagedown({ + input: Object.create(this.clEditor), + }); + this.pagedownEditor.run(); + this.pagedownEditor.hooks.set('insertLinkDialog', (callback) => { + store.dispatch('modal/open', { + type: 'link', + callback, + }); + return true; + }); + this.pagedownEditor.hooks.set('insertImageDialog', (callback) => { + store.dispatch('modal/open', { + type: 'image', + callback, + }); + return true; + }); + this.pagedownEditor.hooks.set('insertChatGptDialog', (callback) => { + store.dispatch('modal/open', { + type: 'chatGpt', + callback, + }); + return true; + }); + this.pagedownEditor.hooks.set('insertImageUploading', (callback) => { + callback(store.getters['img/currImgId']); + return true; + }); + this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); + this.previewElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); + + const refreshPreview = allowDebounce(() => { + this.convert(); + if (instantPreview) { + this.refreshPreview(); + this.measureSectionDimensions(false, true); + } else { + setTimeout(() => this.refreshPreview(), 10); + } + instantPreview = false; + }, 25); + + let newSectionList; + let newSelectionRange; + const onEditorChanged = allowDebounce(() => { + if (this.sectionList !== newSectionList) { + this.sectionList = newSectionList; + this.$emit('sectionList', this.sectionList); + refreshPreview(!instantPreview); + } + if (this.selectionRange !== newSelectionRange) { + this.selectionRange = newSelectionRange; + this.$emit('selectionRange', this.selectionRange); + } + this.saveContentState(); + }, 10); + + this.clEditor.selectionMgr.on('selectionChanged', (start, end, selectionRange) => { + newSelectionRange = selectionRange; + onEditorChanged(!instantPreview); + }); + + /* ----------------------------- + * Inline images + */ + + const imgCache = Object.create(null); + + const hashImgElt = imgElt => `${imgElt.src}:${imgElt.width || -1}:${imgElt.height || -1}`; + + const addToImgCache = (imgElt) => { + const hash = hashImgElt(imgElt); + let entries = imgCache[hash]; + if (!entries) { + entries = []; + imgCache[hash] = entries; + } + entries.push(imgElt); + }; + + const getFromImgCache = (imgEltsToCache) => { + const hash = hashImgElt(imgEltsToCache); + const entries = imgCache[hash]; + if (!entries) { + return null; + } + let imgElt; + return entries + .some((entry) => { + if (this.editorElt.contains(entry)) { + return false; + } + imgElt = entry; + return true; + }) && imgElt; + }; + + const triggerImgCacheGc = cledit.Utils.debounce(() => { + Object.entries(imgCache).forEach(([src, entries]) => { + // Filter entries that are not attached to the DOM + const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt)); + if (filteredEntries.length) { + imgCache[src] = filteredEntries; + } else { + delete imgCache[src]; + } + }); + }, 100); + + let imgEltsToCache = []; + if (store.getters['data/computedSettings'].editor.inlineImages) { + this.clEditor.highlighter.on('sectionHighlighted', (section) => { + const loadImgs = []; + section.elt.getElementsByClassName('token img').cl_each((imgTokenElt) => { + const srcElt = imgTokenElt.querySelector('.token.cl-src'); + if (srcElt) { + // Create an img element before the .img.token and wrap both elements + // into a .token.img-wrapper + const imgElt = document.createElement('img'); + imgElt.style.display = 'none'; + const uri = srcElt.textContent; + if (!/^unsafe/.test(htmlSanitizer.sanitizeUri(uri, true))) { + imgElt.onload = () => { + imgElt.style.display = ''; + }; + imgElt.src = uri; + // Take img size into account + const sizeElt = imgTokenElt.querySelector('.token.cl-size'); + if (sizeElt) { + const match = sizeElt.textContent.match(/=(\d*)x(\d*)/); + if (match[1]) { + imgElt.width = parseInt(match[1], 10); + } + if (match[2]) { + imgElt.height = parseInt(match[2], 10); + } + } + imgEltsToCache.push(imgElt); + if (imgElt.src.indexOf(origin) >= 0) { + imgElt.removeAttribute('src'); + loadImgs.push({ imgElt, uri: decodeURIComponent(uri) }); + } + } + const imgTokenWrapper = document.createElement('span'); + imgTokenWrapper.className = 'token img-wrapper'; + imgTokenElt.parentNode.insertBefore(imgTokenWrapper, imgTokenElt); + imgTokenWrapper.appendChild(imgElt); + imgTokenWrapper.appendChild(imgTokenElt); + } + }); + if (loadImgs.length) { + // Wait for images to load + const loadWorkspaceImg = loadImgs.map(it => new Promise((resolve, reject) => { + getImgUrl(it.uri).then((newUrl) => { + it.imgElt.src = newUrl; + resolve(); + }, () => reject(new Error(`加载当前空间图片出错,uri:${it.uri}`))); + })); + Promise.all(loadWorkspaceImg).then(); + } + }); + } + this.clEditor.highlighter.on('highlighted', () => { + imgEltsToCache.forEach((imgElt) => { + const cachedImgElt = getFromImgCache(imgElt); + if (cachedImgElt) { + // Found a previously loaded image that has just been released + imgElt.parentNode.replaceChild(cachedImgElt, imgElt); + } else { + addToImgCache(imgElt); + } + }); + imgEltsToCache = []; + // Eject released images from cache + triggerImgCacheGc(); + }); + + this.clEditor.on('contentChanged', (content, diffs, sectionList) => { + newSectionList = sectionList; + onEditorChanged(!instantPreview); + }); + + // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2')) + // var previewElt = element[0].querySelector('.preview') + // clEditorSvc.isPreviewTop = previewElt.scrollTop < 10 + // previewElt.addEventListener('scroll', function () { + // var isPreviewTop = previewElt.scrollTop < 10 + // if (isPreviewTop !== clEditorSvc.isPreviewTop) { + // clEditorSvc.isPreviewTop = isPreviewTop + // scope.$apply() + // } + // }) + + // Watch file content changes + let lastContentId = null; + let lastProperties; + store.watch( + () => store.getters['content/currentChangeTrigger'], + () => { + const content = store.getters['content/current']; + // Track ID changes + let initClEditor = false; + if (content.id !== lastContentId) { + instantPreview = true; + lastContentId = content.id; + initClEditor = true; + } + // Track properties changes + if (content.properties !== lastProperties) { + lastProperties = content.properties; + const options = extensionSvc.getOptions(store.getters['content/currentProperties']); + if (utils.serializeObject(options) !== utils.serializeObject(this.options)) { + this.options = options; + this.initPrism(); + this.initConverter(); + initClEditor = true; + } + } + if (initClEditor) { + this.initClEditor(); + } + // Apply potential text and discussion changes + this.applyContent(); + }, { + immediate: true, + }, + ); + + // Disable editor if hidden or if no content is loaded + store.watch( + () => store.getters['content/isCurrentEditable'], + editable => this.clEditor.toggleEditable(!!editable), { + immediate: true, + }, + ); + + store.watch( + () => utils.serializeObject(store.getters['layout/styles']), + () => this.measureSectionDimensions(false, true, true), + ); + + this.initHighlighters(); + this.$emit('inited'); + }, +}); + +export default editorSvc; diff --git a/src/services/explorerSvc.js b/src/services/explorerSvc.js new file mode 100644 index 0000000..0735f73 --- /dev/null +++ b/src/services/explorerSvc.js @@ -0,0 +1,98 @@ +import store from '../store'; +import workspaceSvc from './workspaceSvc'; +import badgeSvc from './badgeSvc'; + +export default { + newItem(isFolder = false) { + let parentId = store.getters['explorer/selectedNodeFolder'].item.id; + if (parentId === 'trash' // Not allowed to create new items in the trash + || (isFolder && parentId === 'temp') // Not allowed to create new folders in the temp folder + ) { + parentId = null; + } + store.dispatch('explorer/openNode', parentId); + store.commit('explorer/setNewItem', { + type: isFolder ? 'folder' : 'file', + parentId, + }); + }, + async deleteItem() { + const selectedNode = store.getters['explorer/selectedNode']; + if (selectedNode.isNil) { + return; + } + + if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') { + try { + await store.dispatch('modal/open', 'trashDeletion'); + } catch (e) { + // Cancel + } + return; + } + + // See if we have a confirmation dialog to show + let moveToTrash = true; + try { + if (selectedNode.isTemp) { + await store.dispatch('modal/open', 'tempFolderDeletion'); + moveToTrash = false; + } else if (selectedNode.item.parentId === 'temp') { + await store.dispatch('modal/open', { + type: 'tempFileDeletion', + item: selectedNode.item, + }); + moveToTrash = false; + } else if (selectedNode.isFolder) { + await store.dispatch('modal/open', { + type: 'folderDeletion', + item: selectedNode.item, + }); + } + } catch (e) { + return; // cancel + } + + const deleteFile = (id) => { + if (moveToTrash) { + workspaceSvc.setOrPatchItem({ + id, + parentId: 'trash', + }); + } else { + workspaceSvc.deleteFile(id); + } + }; + + if (selectedNode === store.getters['explorer/selectedNode']) { + const currentFileId = store.getters['file/current'].id; + let doClose = selectedNode.item.id === currentFileId; + if (selectedNode.isFolder) { + const recursiveDelete = (folderNode) => { + folderNode.folders.forEach(recursiveDelete); + folderNode.files.forEach((fileNode) => { + doClose = doClose || fileNode.item.id === currentFileId; + deleteFile(fileNode.item.id); + }); + store.commit('folder/deleteItem', folderNode.item.id); + }; + recursiveDelete(selectedNode); + badgeSvc.addBadge('removeFolder'); + } else { + deleteFile(selectedNode.item.id); + badgeSvc.addBadge('removeFile'); + } + if (doClose) { + // Close the current file by opening the last opened, not deleted one + store.getters['data/lastOpenedIds'].some((id) => { + const file = store.state.file.itemsById[id]; + if (file.parentId === 'trash') { + return false; + } + store.commit('file/setCurrentId', id); + return true; + }); + } + } + }, +}; diff --git a/src/services/exportSvc.js b/src/services/exportSvc.js new file mode 100644 index 0000000..4106474 --- /dev/null +++ b/src/services/exportSvc.js @@ -0,0 +1,193 @@ +import md5 from 'js-md5'; +import FileSaver from 'file-saver'; +import TemplateWorker from 'worker-loader!./templateWorker.js'; // eslint-disable-line +import localDbSvc from './localDbSvc'; +import markdownConversionSvc from './markdownConversionSvc'; +import extensionSvc from './extensionSvc'; +import utils from './utils'; +import store from '../store'; +import htmlSanitizer from '../libs/htmlSanitizer'; + +function groupHeadings(headings, level = 1) { + const result = []; + let currentItem; + + function pushCurrentItem() { + if (currentItem) { + if (currentItem.children.length > 0) { + currentItem.children = groupHeadings(currentItem.children, level + 1); + } + result.push(currentItem); + } + } + headings.forEach((heading) => { + if (heading.level !== level) { + currentItem = currentItem || { + children: [], + }; + currentItem.children.push(heading); + } else { + pushCurrentItem(); + currentItem = heading; + } + }); + pushCurrentItem(); + return result; +} + +const getImgBase64 = async (uri) => { + if (uri.indexOf('http://') !== 0 && uri.indexOf('https://') !== 0) { + const currDirNode = store.getters['explorer/selectedNodeFolder']; + const absoluteImgPath = utils.getAbsoluteFilePath(currDirNode, uri); + const md5Id = md5(absoluteImgPath); + const imgItem = await localDbSvc.getImgItem(md5Id); + if (imgItem) { + const potIdx = uri.lastIndexOf('.'); + const suffix = potIdx > -1 ? uri.substring(potIdx + 1) : 'png'; + const mime = `image/${suffix}`; + return `data:${mime};base64,${imgItem.content}`; + } + return ''; + } + return uri; +}; + + +const containerElt = document.createElement('div'); +containerElt.className = 'hidden-rendering-container'; +document.body.appendChild(containerElt); + +export default { + /** + * Apply the template to the file content + */ + async applyTemplate(fileId, template = { + value: '{{{files.0.content.text}}}', + helpers: '', + }, pdf = false) { + const file = store.state.file.itemsById[fileId]; + const content = await localDbSvc.loadItem(`${fileId}/content`); + const properties = utils.computeProperties(content.properties); + const options = extensionSvc.getOptions(properties); + const converter = markdownConversionSvc.createConverter(options, true); + const parsingCtx = markdownConversionSvc.parseSections(converter, content.text); + const conversionCtx = markdownConversionSvc.convert(parsingCtx); + const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join(''); + const colorThemeClass = `app--${store.getters['data/computedSettings'].colorTheme}`; + const themeClass = `preview-theme--${store.state.theme.currPreviewTheme}`; + let themeStyleContent = ''; + const themeStyleEle = document.getElementById(`preview-theme-${store.state.theme.currPreviewTheme}`); + if (themeStyleEle) { + themeStyleContent = themeStyleEle.innerText; + } + containerElt.innerHTML = html; + extensionSvc.sectionPreview(containerElt, options); + + // Unwrap tables + containerElt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => { + while (wrapperElt.firstChild) { + wrapperElt.parentNode.insertBefore(wrapperElt.firstChild, wrapperElt.nextSibling); + } + wrapperElt.parentNode.removeChild(wrapperElt); + }); + + // 替换相对路径图片为blob图片 + const imgs = Array.prototype.slice.call(containerElt.getElementsByTagName('img')).map((imgElt) => { + let uri = imgElt.attributes && imgElt.attributes.src && imgElt.attributes.src.nodeValue; + if (uri && uri.indexOf('http://') !== 0 && uri.indexOf('https://') !== 0) { + uri = decodeURIComponent(uri); + imgElt.removeAttribute('src'); + return { imgElt, uri }; + } + return { imgElt }; + }); + const loadedPromises = imgs.map(it => new Promise((resolve, reject) => { + if (!it.imgElt.src && it.uri) { + getImgBase64(it.uri).then((newUrl) => { + it.imgElt.src = newUrl; + resolve(); + }, () => reject(new Error('加载当前空间图片出错'))); + return; + } + resolve(); + })); + await Promise.all(loadedPromises); + + // Make TOC + const allHeaders = containerElt.querySelectorAll('h1,h2,h3,h4,h5,h6'); + Array.prototype.slice.call(allHeaders).forEach((headingElt) => { + // 创建一个新的 元素 + const contentElt = document.createElement('span'); + contentElt.className = 'content'; + // 将原始内容移动到新的 元素中 + while (headingElt.firstChild) { + contentElt.appendChild(headingElt.firstChild); + } + const prefixElt = document.createElement('span'); + prefixElt.className = 'prefix'; + headingElt.insertBefore(prefixElt, headingElt.firstChild); + // 将新的 元素替换原始元素 + headingElt.appendChild(contentElt); + const suffixElt = document.createElement('span'); + suffixElt.className = 'suffix'; + headingElt.appendChild(suffixElt); + }); + const headings = allHeaders.cl_map(headingElt => ({ + title: headingElt.textContent, + anchor: headingElt.id, + level: parseInt(headingElt.tagName.slice(1), 10), + children: [], + })); + const toc = groupHeadings(headings); + const view = { + pdf, + files: [{ + name: file.name, + content: { + text: content.text, + properties, + yamlProperties: content.properties, + html: containerElt.innerHTML, + toc, + colorThemeClass, + themeClass, + themeStyleContent, + }, + }], + }; + containerElt.innerHTML = ''; + + // Run template conversion in a Worker to prevent attacks from helpers + const worker = new TemplateWorker(); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + worker.terminate(); + reject(new Error('Template generation timeout.')); + }, 10000); + worker.addEventListener('message', (e) => { + clearTimeout(timeoutId); + worker.terminate(); + // e.data can contain unsafe data if helpers attempts to call postMessage + const [err, result] = e.data; + if (err) { + reject(new Error(`${err}`)); + } else { + resolve(`${result}`); + } + }); + worker.postMessage([template.value, view, template.helpers]); + }); + }, + + /** + * Export a file to disk. + */ + async exportToDisk(fileId, type, template) { + const file = store.state.file.itemsById[fileId]; + const html = await this.applyTemplate(fileId, template); + const blob = new Blob([html], { + type: 'text/plain;charset=utf-8', + }); + FileSaver.saveAs(blob, `${file.name}.${type}`); + }, +}; diff --git a/src/services/extensionSvc.js b/src/services/extensionSvc.js new file mode 100644 index 0000000..b288259 --- /dev/null +++ b/src/services/extensionSvc.js @@ -0,0 +1,37 @@ +const getOptionsListeners = []; +const initConverterListeners = []; +const sectionPreviewListeners = []; + +export default { + onGetOptions(listener) { + getOptionsListeners.push(listener); + }, + + onInitConverter(priority, listener) { + initConverterListeners[priority] = listener; + }, + + onSectionPreview(listener) { + sectionPreviewListeners.push(listener); + }, + + getOptions(properties, isCurrentFile) { + return getOptionsListeners.reduce((options, listener) => { + listener(options, properties, isCurrentFile); + return options; + }, {}); + }, + + initConverter(markdown, options) { + // Use forEach as it's a sparsed array + initConverterListeners.forEach((listener) => { + listener(markdown, options); + }); + }, + + sectionPreview(elt, options, isEditor) { + sectionPreviewListeners.forEach((listener) => { + listener(elt, options, isEditor); + }); + }, +}; diff --git a/src/services/gitWorkspaceSvc.js b/src/services/gitWorkspaceSvc.js new file mode 100644 index 0000000..67a8b59 --- /dev/null +++ b/src/services/gitWorkspaceSvc.js @@ -0,0 +1,236 @@ +import store from '../store'; +import utils from '../services/utils'; + +const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix; + +export default { + shaByPath: Object.create(null), + makeChanges(tree) { + const workspacePath = store.getters['workspace/currentWorkspace'].path || ''; + + // Store all blobs sha + this.shaByPath = Object.create(null); + // Store interesting paths + const treeFolderMap = Object.create(null); + const treeFileMap = Object.create(null); + const treeDataMap = Object.create(null); + const treeSyncLocationMap = Object.create(null); + const treePublishLocationMap = Object.create(null); + + tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0) + .forEach((blobEntry) => { + // Make path relative + const path = blobEntry.path.slice(workspacePath.length); + // Collect blob sha + this.shaByPath[path] = blobEntry.sha; + if (path.indexOf('.stackedit-data/') === 0) { + treeDataMap[path] = true; + } else { + // Collect parents path + let parentPath = ''; + path.split('/').slice(0, -1).forEach((folderName) => { + const folderPath = `${parentPath}${folderName}/`; + treeFolderMap[folderPath] = parentPath; + parentPath = folderPath; + }); + // Collect file path + if (endsWith(path, '.md')) { + treeFileMap[path] = parentPath; + } else if (endsWith(path, '.sync')) { + treeSyncLocationMap[path] = true; + } else if (endsWith(path, '.publish')) { + treePublishLocationMap[path] = true; + } + } + }); + + // Collect changes + const changes = []; + const idsByPath = {}; + const syncDataByPath = store.getters['data/syncDataById']; + const { itemIdsByGitPath } = store.getters; + const getIdFromPath = (path, isFile) => { + let itemId = idsByPath[path]; + if (!itemId) { + const existingItemId = itemIdsByGitPath[path]; + if (existingItemId + // Reuse a file ID only if it has already been synced + && (!isFile || syncDataByPath[path] + // Content may have already been synced + || syncDataByPath[`/${path}`]) + ) { + itemId = existingItemId; + } else { + // Otherwise, make a new ID for a new item + itemId = utils.uid(); + } + // If it's a file path, add the content path as well + if (isFile) { + idsByPath[`/${path}`] = `${itemId}/content`; + } + idsByPath[path] = itemId; + } + return itemId; + }; + + // Folder creations/updates + // Assume map entries are sorted from top to bottom + Object.entries(treeFolderMap).forEach(([path, parentPath]) => { + if (path === '.stackedit-trash/') { + idsByPath[path] = 'trash'; + } else { + const item = utils.addItemHash({ + id: getIdFromPath(path), + type: 'folder', + name: path.slice(parentPath.length, -1), + parentId: idsByPath[parentPath] || null, + }); + + const folderSyncData = syncDataByPath[path]; + if (!folderSyncData || folderSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + } + }); + + // File/content creations/updates + Object.entries(treeFileMap).forEach(([path, parentPath]) => { + const fileId = getIdFromPath(path, true); + const contentPath = `/${path}`; + const contentId = idsByPath[contentPath]; + + // File creations/updates + const item = utils.addItemHash({ + id: fileId, + type: 'file', + name: path.slice(parentPath.length, -'.md'.length), + parentId: idsByPath[parentPath] || null, + }); + + const fileSyncData = syncDataByPath[path]; + if (!fileSyncData || fileSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + + // Content creations/updates + const contentSyncData = syncDataByPath[contentPath]; + if (!contentSyncData || contentSyncData.sha !== this.shaByPath[path]) { + const type = 'content'; + // Use `/` as a prefix to get a unique syncData id + changes.push({ + syncDataId: contentPath, + item: { + id: contentId, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + syncData: { + id: contentPath, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + }); + } + }); + + // Data creations/updates + const syncDataById = store.getters['data/syncDataById']; + Object.keys(treeDataMap).forEach((path) => { + // Only settings、workspaces、template data are stored + const [, id] = path.match(/^\.stackedit-data\/(settings|workspaces|badgeCreations|templates)\.json$/) || []; + if (id) { + idsByPath[path] = id; + idsByPath[id] = id; + const syncData = syncDataById[id]; + if (!syncData || syncData.sha !== this.shaByPath[path]) { + const type = 'data'; + changes.push({ + syncDataId: id, + item: { + id, + type, + // Need a truthy value to force saving sync data + hash: 1, + }, + syncData: { + id, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + }); + } + } + }); + + // Location creations/updates + [{ + type: 'syncLocation', + map: treeSyncLocationMap, + pathMatcher: /^([\s\S]+)\.([\w-]+)\.sync$/, + }, { + type: 'publishLocation', + map: treePublishLocationMap, + pathMatcher: /^([\s\S]+)\.([\w-]+)\.publish$/, + }] + .forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => { + const [, filePath, data] = path.match(pathMatcher) || []; + if (filePath) { + // If there is a corresponding md file in the tree + const fileId = idsByPath[`${filePath}.md`]; + if (fileId) { + // Reuse existing ID or create a new one + const id = itemIdsByGitPath[path] || utils.uid(); + idsByPath[path] = id; + + const item = utils.addItemHash({ + ...JSON.parse(utils.decodeBase64(data)), + id, + type, + fileId, + }); + + const locationSyncData = syncDataByPath[path]; + if (!locationSyncData || locationSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + } + } + })); + + // Deletions + Object.keys(syncDataByPath).forEach((path) => { + if (!idsByPath[path]) { + changes.push({ syncDataId: path }); + } + }); + + return changes; + }, +}; diff --git a/src/services/imageSvc.js b/src/services/imageSvc.js new file mode 100644 index 0000000..99228c8 --- /dev/null +++ b/src/services/imageSvc.js @@ -0,0 +1,94 @@ +import md5 from 'js-md5'; +import store from '../store'; +import utils from './utils'; +import localDbSvc from './localDbSvc'; +import smmsHelper from '../services/providers/helpers/smmsHelper'; +import giteaHelper from '../services/providers/helpers/giteaHelper'; +import githubHelper from '../services/providers/helpers/githubHelper'; +import customHelper from '../services/providers/helpers/customHelper'; + +const getImagePath = (confPath, imgType) => { + const time = new Date(); + const date = time.getDate(); + const month = time.getMonth() + 1; + const year = time.getFullYear(); + const path = confPath.replace('{YYYY}', year).replace('{MM}', `0${month}`.slice(-2)) + .replace('{DD}', `0${date}`.slice(-2)).replace('{MDNAME}', store.getters['file/current'].name); + return `${path}${path.endsWith('/') ? '' : '/'}${utils.uid()}.${imgType.split('/')[1]}`; +}; + +export default { + // 上传图片 返回图片链接 + // { url: 'http://xxxx', error: 'xxxxxx'} + async updateImg(imgFile) { + // 操作图片上传 + const currStorage = store.getters['img/getCheckedStorage']; + if (!currStorage) { + return { error: '暂无已选择的图床!' }; + } + // 判断是否文档空间路径 + if (currStorage.type === 'workspace') { + // 如果不是git仓库 则提示不支持 + if (!store.getters['workspace/currentWorkspaceIsGit']) { + return { error: '暂无已选择的图床!' }; + } + const path = getImagePath(currStorage.sub, imgFile.type); + // 保存到indexeddb + const base64 = await utils.encodeFiletoBase64(imgFile); + const currDirNode = store.getters['explorer/selectedNodeFolder']; + const absolutePath = utils.getAbsoluteFilePath(currDirNode, path); + await localDbSvc.saveImg({ + id: md5(absolutePath), + path: absolutePath, + content: base64, + }); + return { url: path.replaceAll(' ', '%20') }; + } + if (!currStorage.provider) { + return { error: '暂无已选择的图床!' }; + } + const token = store.getters[`data/${currStorage.provider}TokensBySub`][currStorage.sub]; + if (!token) { + return { error: '暂无已选择的图床!' }; + } + let url = ''; + // token图床类型 + if (currStorage.type === 'token') { + const helper = currStorage.provider === 'smms' ? smmsHelper : customHelper; + url = await helper.uploadFile({ + token, + file: imgFile, + }); + } else if (currStorage.type === 'tokenRepo') { // git repo图床类型 + const checkStorages = token.imgStorages.filter(it => it.sid === currStorage.sid); + if (!checkStorages || checkStorages.length === 0) { + return { error: '暂无已选择的图床!' }; + } + const checkStorage = checkStorages[0]; + const path = getImagePath(checkStorage.path, imgFile.type); + if (currStorage.provider === 'gitea') { + const result = await giteaHelper.uploadFile({ + token, + projectId: checkStorage.repoUri, + branch: checkStorage.branch, + path, + content: imgFile, + isImg: true, + }); + url = result.content.download_url; + } else if (currStorage.provider === 'github') { + const result = await githubHelper.uploadFile({ + token, + owner: checkStorage.owner, + repo: checkStorage.repo, + branch: checkStorage.branch, + path, + content: imgFile, + isImg: true, + }); + url = result.content.download_url; + } + } + return { url }; + }, +}; diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js new file mode 100644 index 0000000..11b4fc0 --- /dev/null +++ b/src/services/localDbSvc.js @@ -0,0 +1,510 @@ +import utils from './utils'; +import store from '../store'; +import welcomeFile from '../data/welcomeFile.md'; +import workspaceSvc from './workspaceSvc'; +import constants from '../data/constants'; + +const deleteMarkerMaxAge = 1000; +const dbVersion = 3; +const dbStoreName = 'objects'; +const imgDbStoreName = 'imgs'; +const imgWaitUploadIdsKey = 'waitUploadImgIds'; +const { silent } = utils.queryParams; +const resetApp = localStorage.getItem('resetStackEdit'); +if (resetApp) { + localStorage.removeItem('resetStackEdit'); +} + +class Connection { + constructor(workspaceId = store.getters['workspace/currentWorkspace'].id) { + this.getTxCbs = []; + + // Make the DB name + this.dbName = utils.getDbName(workspaceId); + + // Init connection + const request = indexedDB.open(this.dbName, dbVersion); + + request.onerror = () => { + throw new Error('无法连接到IndexedDB.'); + }; + + request.onsuccess = (event) => { + this.db = event.target.result; + this.db.onversionchange = () => window.location.reload(); + + this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError)); + this.getTxCbs = null; + }; + + request.onupgradeneeded = (event) => { + const eventDb = event.target.result; + // const oldVersion = event.oldVersion || 0; + if (!eventDb.objectStoreNames.contains(dbStoreName)) { + // Create store + const dbStore = eventDb.createObjectStore(dbStoreName, { + keyPath: 'id', + }); + dbStore.createIndex('tx', 'tx', { + unique: false, + }); + } + if (!eventDb.objectStoreNames.contains(imgDbStoreName)) { + eventDb.createObjectStore(imgDbStoreName, { + keyPath: 'id', + }); + } + }; + } + + /** + * Create a transaction asynchronously. + */ + createTx(onTx, onError) { + // If DB is not ready, keep callbacks for later + if (!this.db) { + return this.getTxCbs.push({ onTx, onError }); + } + + // Open transaction in read/write will prevent conflict with other tabs + const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite'); + tx.onerror = onError; + + return onTx(tx); + } +} + +const contentTypes = { + content: true, + contentState: true, + syncedContent: true, +}; + +const hashMap = {}; +constants.types.forEach((type) => { + hashMap[type] = Object.create(null); +}); +const lsHashMap = Object.create(null); + +const localDbSvc = { + lastTx: 0, + hashMap, + connection: null, + + /** + * Sync data items stored in the localStorage. + */ + syncLocalStorage() { + constants.localStorageDataIds.forEach((id) => { + const key = `data/${id}`; + + // Skip reloading the layoutSettings + if (id !== 'layoutSettings' || !lsHashMap[id]) { + try { + // Try to parse the item from the localStorage + const storedItem = JSON.parse(localStorage.getItem(key)); + if (storedItem.hash && lsHashMap[id] !== storedItem.hash) { + // Item has changed, replace it in the store + store.commit('data/setItem', storedItem); + lsHashMap[id] = storedItem.hash; + } + } catch (e) { + // Ignore parsing issue + } + } + + // Write item if different from stored one + const item = store.state.data.lsItemsById[id]; + if (item && item.hash !== lsHashMap[id]) { + localStorage.setItem(key, JSON.stringify(item)); + lsHashMap[id] = item.hash; + } + }); + }, + + /** + * Return a promise that will be resolved once the synchronization between the store and the + * localDb will be finished. Effectively, open a transaction, then read and apply all changes + * from the DB since the previous transaction, then write all the changes from the store. + */ + async sync() { + return new Promise((resolve, reject) => { + // Create the DB transaction + this.connection.createTx((tx) => { + const { lastTx } = this; + + // Look for DB changes and apply them to the store + this.readAll(tx, (storeItemMap) => { + // Sanitize the workspace if changes have been applied + if (lastTx !== this.lastTx) { + workspaceSvc.sanitizeWorkspace(); + } + + // Persist all the store changes into the DB + this.writeAll(storeItemMap, tx); + // Sync the localStorage + this.syncLocalStorage(); + // Done + resolve(); + }); + }, () => reject(new Error('Local DB access error.'))); + }); + }, + + /** + * Read and apply all changes from the DB since previous transaction. + */ + readAll(tx, cb) { + let { lastTx } = this; + const dbStore = tx.objectStore(dbStoreName); + const index = dbStore.index('tx'); + const range = IDBKeyRange.lowerBound(this.lastTx, true); + const changes = []; + index.openCursor(range).onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + const item = cursor.value; + if (item.tx > lastTx) { + lastTx = item.tx; + if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) { + // We may have missed some delete markers + window.location.reload(); + return; + } + } + // Collect change + changes.push(item); + cursor.continue(); + return; + } + + // Read the collected changes + const storeItemMap = { ...store.getters.allItemsById }; + changes.forEach((item) => { + this.readDbItem(item, storeItemMap); + // If item is an old delete marker, remove it from the DB + if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) { + dbStore.delete(item.id); + } + }); + + this.lastTx = lastTx; + cb(storeItemMap); + }; + }, + async saveImg(imgItem) { + await this.writeImgItem(imgItem); + const waitUploadIdsItem = (await this.getImgItem(imgWaitUploadIdsKey)) + || { id: imgWaitUploadIdsKey, ids: [] }; + const waitUplodIds = waitUploadIdsItem.ids || []; + // 如果已上传 + if (imgItem.uploaded) { + waitUplodIds.splice(waitUplodIds.indexOf(imgItem.id), 1); + } else { + waitUplodIds.push(imgItem.id); + } + waitUploadIdsItem.ids = waitUplodIds; + await this.writeImgItem(waitUploadIdsItem); + }, + // 获取待上传的图片id + async getWaitUploadImgIds() { + const waitUploadIdsItem = (await this.getImgItem(imgWaitUploadIdsKey)) + || { id: imgWaitUploadIdsKey, ids: [] }; + return waitUploadIdsItem.ids || []; + }, + /** + * 写入图片 + */ + async writeImgItem(imgItem) { + return new Promise((resolve, reject) => { + // Create the DB transaction + this.connection.createTx((tx) => { + const dbStore = tx.objectStore(imgDbStoreName); + dbStore.put(imgItem); + resolve(); + }, () => reject(new Error('保存图片异常'))); + }); + }, + /** + * 读取图片 + */ + async getImgItem(id) { + return new Promise((resolve, reject) => { + // Get the item from DB + this.connection.createTx((tx) => { + const dbStore = tx.objectStore(imgDbStoreName); + const request = dbStore.get(id); + request.onsuccess = () => { + const dbItem = request.result; + resolve(dbItem); + }; + }, () => reject(new Error('indexeddb获取图片异常'))); + }); + }, + + /** + * Write all changes from the store since previous transaction. + */ + writeAll(storeItemMap, tx) { + if (silent) { + // Skip writing to DB in silent mode + return; + } + const dbStore = tx.objectStore(dbStoreName); + const incrementedTx = this.lastTx + 1; + + // Remove deleted store items + Object.keys(this.hashMap).forEach((type) => { + // Remove this type only if file is deleted + let checker = cb => id => !storeItemMap[id] && cb(id); + if (contentTypes[type]) { + // For content types, remove item only if file is deleted + checker = cb => (id) => { + if (!storeItemMap[id]) { + const [fileId] = id.split('/'); + if (!store.state.file.itemsById[fileId]) { + cb(id); + } + } + }; + } + Object.keys(this.hashMap[type]).forEach(checker((id) => { + // Put a delete marker to notify other tabs + dbStore.put({ + id, + type, + tx: incrementedTx, + }); + delete this.hashMap[type][id]; + this.lastTx = incrementedTx; + })); + }); + + // Put changes + Object.entries(storeItemMap).forEach(([, storeItem]) => { + // Store object has changed + if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) { + const item = { + ...storeItem, + tx: incrementedTx, + }; + dbStore.put(item); + this.hashMap[item.type][item.id] = item.hash; + this.lastTx = incrementedTx; + } + }); + }, + + /** + * Read and apply one DB change. + */ + readDbItem(dbItem, storeItemMap) { + const storeItem = storeItemMap[dbItem.id]; + if (!dbItem.hash) { + // DB item is a delete marker + delete this.hashMap[dbItem.type][dbItem.id]; + if (storeItem) { + // Remove item from the store + store.commit(`${storeItem.type}/deleteItem`, storeItem.id); + delete storeItemMap[storeItem.id]; + } + } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) { + // DB item is different from the corresponding store item + this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; + // Update content only if it exists in the store + if (storeItem || !contentTypes[dbItem.type]) { + // Put item in the store + dbItem.tx = undefined; + store.commit(`${dbItem.type}/setItem`, dbItem); + storeItemMap[dbItem.id] = dbItem; + } + } + }, + + /** + * Retrieve an item from the DB and put it in the store. + */ + async loadItem(id) { + // Check if item is in the store + const itemInStore = store.getters.allItemsById[id]; + if (itemInStore) { + // Use deepCopy to freeze item + return Promise.resolve(itemInStore); + } + return new Promise((resolve, reject) => { + // Get the item from DB + const onError = () => reject(new Error('Data not available.')); + this.connection.createTx((tx) => { + const dbStore = tx.objectStore(dbStoreName); + const request = dbStore.get(id); + request.onsuccess = () => { + const dbItem = request.result; + if (!dbItem || !dbItem.hash) { + onError(); + } else { + this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; + // Put item in the store + dbItem.tx = undefined; + store.commit(`${dbItem.type}/setItem`, dbItem); + resolve(dbItem); + } + }; + }, () => onError()); + }); + }, + + /** + * Unload from the store contents that haven't been opened recently + */ + async unloadContents() { + await this.sync(); + // Keep only last opened files in memory + const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']); + Object.keys(contentTypes).forEach((type) => { + store.getters[`${type}/items`].forEach((item) => { + const [fileId] = item.id.split('/'); + if (!lastOpenedFileIdSet.has(fileId)) { + // Remove item from the store + store.commit(`${type}/deleteItem`, item.id); + } + }); + }); + }, + + /** + * Create the connection and start syncing. + */ + async init() { + // Reset the app if the reset flag was passed + if (resetApp) { + await Promise.all(Object.keys(store.getters['workspace/workspacesById']) + .map(workspaceId => workspaceSvc.removeWorkspace(workspaceId))); + constants.localStorageDataIds.forEach((id) => { + // Clean data stored in localStorage + localStorage.removeItem(`data/${id}`); + }); + throw new Error('RELOAD'); + } + + // Create the connection + this.connection = new Connection(); + + // Load the DB + await localDbSvc.sync(); + + // Watch workspace deletions and persist them as soon as possible + // to make the changes available to reloading workspace tabs. + store.watch( + () => store.getters['data/workspaces'], + () => this.syncLocalStorage(), + ); + + // Save welcome file content hash if not done already + const hash = utils.hash(welcomeFile); + const { welcomeFileHashes } = store.getters['data/localSettings']; + if (!welcomeFileHashes[hash]) { + store.dispatch('data/patchLocalSettings', { + welcomeFileHashes: { + ...welcomeFileHashes, + [hash]: 1, + }, + }); + } + + // If app was last opened 7 days ago and synchronization is off + if (!store.getters['workspace/syncToken'] && + (store.state.workspace.lastFocus + constants.cleanTrashAfter < Date.now()) + ) { + // Clean files + store.getters['file/items'] + .filter(file => file.parentId === 'trash') // If file is in the trash + .forEach(file => workspaceSvc.deleteFile(file.id)); + } + + // Sync local DB periodically + utils.setInterval(() => localDbSvc.sync(), 1000); + + // watch current file changing + store.watch( + () => store.getters['file/current'].id, + async () => { + // See if currentFile is real, ie it has an ID + const currentFile = store.getters['file/current']; + // If current file has no ID, get the most recent file + if (!currentFile.id) { + const recentFile = store.getters['file/lastOpened']; + // Set it as the current file + if (recentFile.id) { + store.commit('file/setCurrentId', recentFile.id); + } else { + // If still no ID, create a new file + const newFile = await workspaceSvc.createFile({ + name: 'Welcome file', + text: welcomeFile, + }, true); + // Set it as the current file + store.commit('file/setCurrentId', newFile.id); + } + } else { + try { + // Load contentState from DB + await localDbSvc.loadContentState(currentFile.id); + // Load syncedContent from DB + await localDbSvc.loadSyncedContent(currentFile.id); + // Load content from DB + try { + await localDbSvc.loadItem(`${currentFile.id}/content`); + } catch (err) { + // Failure (content is not available), go back to previous file + const lastOpenedFile = store.getters['file/lastOpened']; + store.commit('file/setCurrentId', lastOpenedFile.id); + throw err; + } + // Set last opened file + store.dispatch('data/setLastOpenedId', currentFile.id); + // Cancel new discussion and open the gutter if file contains discussions + store.commit( + 'discussion/setCurrentDiscussionId', + store.getters['discussion/nextDiscussionId'], + ); + } catch (err) { + console.error(err); // eslint-disable-line no-console + store.dispatch('notification/error', err); + } + } + }, + { immediate: true }, + ); + }, + + getWorkspaceItems(workspaceId, onItem, onFinish = () => {}) { + const connection = new Connection(workspaceId); + connection.createTx((tx) => { + const dbStore = tx.objectStore(dbStoreName); + const index = dbStore.index('tx'); + index.openCursor().onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + onItem(cursor.value); + cursor.continue(); + } else { + connection.db.close(); + onFinish(); + } + }; + }); + + // Return a cancel function + return () => connection.db.close(); + }, +}; + +const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) + // Item does not exist, create it + .catch(() => store.commit(`${type}/setItem`, { + id: `${fileId}/${type}`, + })); +localDbSvc.loadSyncedContent = loader('syncedContent'); +localDbSvc.loadContentState = loader('contentState'); + +export default localDbSvc; diff --git a/src/services/markdownConversionSvc.js b/src/services/markdownConversionSvc.js new file mode 100644 index 0000000..77fb8a7 --- /dev/null +++ b/src/services/markdownConversionSvc.js @@ -0,0 +1,273 @@ +import DiffMatchPatch from 'diff-match-patch'; +import Prism from 'prismjs'; +import MarkdownIt from 'markdown-it'; +import markdownGrammarSvc from './markdownGrammarSvc'; +import extensionSvc from './extensionSvc'; +import utils from './utils'; + +const htmlSectionMarker = '\uF111\uF222\uF333\uF444'; +const diffMatchPatch = new DiffMatchPatch(); + +// Create aliases for syntax highlighting +const languageAliases = ({ + js: 'javascript', + json: 'javascript', + html: 'markup', + svg: 'markup', + xml: 'markup', + py: 'python', + rb: 'ruby', + yml: 'yaml', + ps1: 'powershell', + psm1: 'powershell', +}); +Object.entries(languageAliases).forEach(([alias, language]) => { + Prism.languages[alias] = Prism.languages[language]; +}); + +// Add programming language parsing capability to markdown fences +const insideFences = {}; +Object.entries(Prism.languages).forEach(([name, language]) => { + if (Prism.util.type(language) === 'Object') { + insideFences[`language-${name}`] = { + pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`), + inside: { + 'cl cl-pre': /(```|~~~).*/, + rest: language, + }, + }; + } +}); + +// Disable spell checking in specific tokens +const noSpellcheckTokens = Object.create(null); +[ + 'code', + 'pre', + 'pre gfm cn-code', + 'math block', + 'math inline', + 'math expr block', + 'math expr inline', + 'latex block', +] + .forEach((key) => { + noSpellcheckTokens[key] = true; + }); +Prism.hooks.add('wrap', (env) => { + if (noSpellcheckTokens[env.type]) { + env.attributes.spellcheck = 'false'; + } +}); + +function createFlagMap(arr) { + return arr.reduce((map, type) => ({ ...map, [type]: true }), {}); +} +const startSectionBlockTypeMap = createFlagMap([ + 'paragraph_open', + 'blockquote_open', + 'heading_open', + 'code', + 'fence', + 'table_open', + 'html_block', + 'bullet_list_open', + 'ordered_list_open', + 'hr', + 'dl_open', +]); +const listBlockTypeMap = createFlagMap([ + 'bullet_list_open', + 'ordered_list_open', +]); +const blockquoteBlockTypeMap = createFlagMap([ + 'blockquote_open', +]); +const tableBlockTypeMap = createFlagMap([ + 'table_open', +]); +const deflistBlockTypeMap = createFlagMap([ + 'dl_open', +]); + +function hashArray(arr, valueHash, valueArray) { + const hash = []; + arr.forEach((str) => { + let strHash = valueHash[str]; + if (strHash === undefined) { + strHash = valueArray.length; + valueArray.push(str); + valueHash[str] = strHash; + } + hash.push(strHash); + }); + return String.fromCharCode.apply(null, hash); +} + +export default { + defaultOptions: null, + defaultConverter: null, + defaultPrismGrammars: null, + + init() { + const defaultProperties = { extensions: utils.computedPresets.default }; + + // Default options for the markdown converter and the grammar + this.defaultOptions = { + ...extensionSvc.getOptions(defaultProperties), + insideFences, + }; + + this.defaultConverter = this.createConverter(this.defaultOptions); + this.defaultPrismGrammars = markdownGrammarSvc.makeGrammars(this.defaultOptions); + }, + + /** + * Creates a converter and init it with extensions. + * @returns {Object} A converter. + */ + createConverter(options) { + // Let the listeners add the rules + const converter = new MarkdownIt('zero'); + converter.core.ruler.enable([], true); + converter.block.ruler.enable([], true); + converter.inline.ruler.enable([], true); + extensionSvc.initConverter(converter, options); + Object.keys(startSectionBlockTypeMap).forEach((type) => { + const rule = converter.renderer.rules[type] || converter.renderer.renderToken; + converter.renderer.rules[type] = (tokens, idx, opts, env, self) => { + if (tokens[idx].sectionDelimiter) { + // Add section delimiter + return htmlSectionMarker + rule.call(converter.renderer, tokens, idx, opts, env, self); + } + return rule.call(converter.renderer, tokens, idx, opts, env, self); + }; + }); + return converter; + }, + + /** + * Parse markdown sections by passing the 2 first block rules of the markdown-it converter. + * @param {Object} converter The markdown-it converter. + * @param {String} text The text to be parsed. + * @returns {Object} A parsing context to be passed to `convert`. + */ + parseSections(converter, text) { + const markdownState = new converter.core.State(text, converter, {}); + const markdownCoreRules = converter.core.ruler.getRules(''); + markdownCoreRules[0](markdownState); // Pass the normalize rule + markdownCoreRules[1](markdownState); // Pass the block rule + const lines = text.split('\n'); + if (!lines[lines.length - 1]) { + // In cledit, last char is always '\n'. + // Remove it as one will be added by addSection + lines.pop(); + } + const parsingCtx = { + text, + sections: [], + converter, + markdownState, + markdownCoreRules, + }; + let data = 'main'; + let i = 0; + + function addSection(maxLine) { + const section = { + text: '', + data, + }; + for (; i < maxLine; i += 1) { + section.text += `${lines[i]}\n`; + } + if (section) { + parsingCtx.sections.push(section); + } + } + markdownState.tokens.forEach((token, index) => { + // index === 0 means there are empty lines at the begining of the file + if (token.level === 0 && startSectionBlockTypeMap[token.type] === true) { + if (index > 0) { + token.sectionDelimiter = true; + addSection(token.map[0]); + } + if (listBlockTypeMap[token.type] === true) { + data = 'list'; + } else if (blockquoteBlockTypeMap[token.type] === true) { + data = 'blockquote'; + } else if (tableBlockTypeMap[token.type] === true) { + data = 'table'; + } else if (deflistBlockTypeMap[token.type] === true) { + data = 'deflist'; + } else { + data = 'main'; + } + } + }); + addSection(lines.length); + return parsingCtx; + }, + + /** + * Convert markdown sections previously parsed with `parseSections`. + * @param {Object} parsingCtx The parsing context returned by `parseSections`. + * @param {Object} previousConversionCtx The conversion context returned by a previous call + * to `convert`, in order to calculate the `htmlSectionDiff` of the returned conversion context. + * @returns {Object} A conversion context. + */ + convert(parsingCtx, previousConversionCtx) { + // This function can be called twice without editor modification + // so prevent from converting it again. + if (!parsingCtx.markdownState.isConverted) { + // Skip 2 first rules previously passed in parseSections + parsingCtx.markdownCoreRules.slice(2).forEach(rule => rule(parsingCtx.markdownState)); + parsingCtx.markdownState.isConverted = true; + } + const { tokens } = parsingCtx.markdownState; + const html = parsingCtx.converter.renderer.render( + tokens, + parsingCtx.converter.options, + parsingCtx.markdownState.env, + ); + const htmlSectionList = html.split(htmlSectionMarker); + if (htmlSectionList[0] === '') { + htmlSectionList.shift(); + } + const valueHash = Object.create(null); + const valueArray = []; + const newSectionHash = hashArray(htmlSectionList, valueHash, valueArray); + let htmlSectionDiff; + if (previousConversionCtx) { + const oldSectionHash = hashArray( + previousConversionCtx.htmlSectionList, + valueHash, + valueArray, + ); + htmlSectionDiff = diffMatchPatch.diff_main(oldSectionHash, newSectionHash, false); + } else { + htmlSectionDiff = [ + [1, newSectionHash], + ]; + } + return { + text: parsingCtx.text, + sectionList: parsingCtx.sectionList, + htmlSectionList, + htmlSectionDiff, + }; + }, + + /** + * Helper to highlight arbitrary markdown + * @param {Object} markdown The markdown content to highlight. + * @param {Object} converter An optional converter. + * @param {Object} grammars Optional grammars. + * @returns {Object} The highlighted markdown in HTML format. + */ + highlight(markdown, converter = this.defaultConverter, grammars = this.defaultPrismGrammars) { + const parsingCtx = this.parseSections(converter, markdown); + return parsingCtx.sections + .map(section => Prism.highlight(section.text, grammars[section.data])).join(''); + }, +}; diff --git a/src/services/markdownGrammarSvc.js b/src/services/markdownGrammarSvc.js new file mode 100644 index 0000000..54677f8 --- /dev/null +++ b/src/services/markdownGrammarSvc.js @@ -0,0 +1,435 @@ +const charInsideUrl = '(&|[-A-Z0-9+@#/%?=~_|[\\]()!:,.;])'; +const charEndingUrl = '(&|[-A-Z0-9+@#/%=~_|[\\])])'; +const urlPattern = new RegExp(`(https?|ftp)(://${charInsideUrl}*${charEndingUrl})(?=$|\\W)`, 'gi'); +const emailPattern = /(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)/gi; + +const markup = { + comment: //g, + tag: { + pattern: /<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|[^\s'">=]+))?\s*)*\/?>/gi, + inside: { + tag: { + pattern: /^<\/?[\w:-]+/i, + inside: { + punctuation: /^<\/?/, + namespace: /^[\w-]+?:/, + }, + }, + 'attr-value': { + pattern: /=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi, + inside: { + punctuation: /=|>|"/g, + }, + }, + punctuation: /\/?>/g, + 'attr-name': { + pattern: /[\w:-]+/g, + inside: { + namespace: /^[\w-]+?:/, + }, + }, + }, + }, + entity: /?[\da-z]{1,8};/gi, +}; + +const latex = { + // A tex command e.g. \foo + keyword: /\\(?:[^a-zA-Z]|[a-zA-Z]+)/g, + // Curly and square braces + lparen: /[[({]/g, + // Curly and square braces + rparen: /[\])}]/g, + // A comment. Tex comments start with % and go to + // the end of the line + comment: /%.*/g, +}; + +export default { + makeGrammars(options) { + const grammars = { + main: {}, + list: {}, + blockquote: {}, + table: {}, + deflist: {}, + }; + + grammars.deflist.deflist = { + pattern: new RegExp( + [ + '^ {0,3}\\S.*\\n', // Description line + '(?:[ \\t]*\\n)?', // Optional empty line + '(?:', + '[ \\t]*:[ \\t].*\\n', // Colon line + '(?:', + '(?:', + '.*\\S.*\\n', // Non-empty line + '|', + '[ \\t]*\\n(?! ?\\S)', // Or empty line not followed by unindented line + ')', + ')*', + '(?:[ \\t]*\\n)*', // Empty lines + ')+', + ].join(''), + 'm', + ), + inside: { + term: /^.+/, + cl: /^[ \t]*:[ \t]/gm, + }, + }; + + const insideFences = options.insideFences || {}; + insideFences['cl cl-pre'] = /```|~~~/; + if (options.fence) { + grammars.main['pre gfm cn-code'] = { + pattern: /^(```|~~~)[\s\S]*?\n\1 *$/gm, + inside: insideFences, + }; + grammars.list['pre gfm cn-code'] = { + pattern: /^(?: {4}|\t)(```|~~~)[\s\S]*?\n(?: {4}|\t)\1\s*$/gm, + inside: insideFences, + }; + grammars.deflist.deflist.inside['pre gfm cn-code'] = grammars.list['pre gfm cn-code']; + } + + grammars.main['h1 alt cn-head'] = { + pattern: /^.+\n[=]{2,}[ \t]*$/gm, + inside: { + 'cl cl-hash': /=+[ \t]*$/, + }, + }; + grammars.main['h2 alt cn-head'] = { + pattern: /^.+\n[-]{2,}[ \t]*$/gm, + inside: { + 'cl cl-hash': /-+[ \t]*$/, + }, + }; + grammars.main['cn-toc'] = { + pattern: /^\[(TOC|toc)\]$/gm, + }; + for (let i = 6; i >= 1; i -= 1) { + grammars.main[`h${i} cn-head`] = { + pattern: new RegExp(`^#{${i}}[ \t].+$`, 'gm'), + inside: { + 'cl cl-hash': new RegExp(`^#{${i}}`), + }, + }; + } + + const list = /^[ \t]*([*+-]|\d+\.)[ \t]/gm; + const blockquote = { + pattern: /^\s*>.*(?:\n[ \t]*\S.*)*/gm, + inside: { + 'cl cl-gt': /^\s*>/gm, + 'cl cl-li': list, + }, + }; + grammars.list.blockquote = blockquote; + grammars.blockquote.blockquote = blockquote; + grammars.deflist.deflist.inside.blockquote = blockquote; + grammars.list['cl cl-li'] = list; + grammars.blockquote['cl cl-li'] = list; + grammars.deflist.deflist.inside['cl cl-li'] = list; + + grammars.table.table = { + pattern: new RegExp( + [ + '^\\s*\\S.*[|].*\\n', // Header Row + '[-| :]+\\n', // Separator + '(?:.*[|].*\\n?)*', // Table rows + '$', + ].join(''), + 'gm', + ), + inside: { + 'cl cl-title-separator': /^[-| :]+$/gm, + 'cl cl-pipe': /[|]/gm, + }, + }; + + grammars.main.hr = { + pattern: /^ {0,3}([*\-_] *){3,}$/gm, + }; + + if (options.tasklist) { + grammars.list.task = { + pattern: /^\[[ xX]\] /, + inside: { + cl: /[[\]]/, + strong: /[xX]/, + }, + }; + } + + const defs = {}; + if (options.footnote) { + defs.fndef = { + pattern: /^ {0,3}\[\^.*?\]:.*$/gm, + inside: { + 'ref-id': { + pattern: /^ {0,3}\[\^.*?\]/, + inside: { + cl: /(\[\^|\])/, + }, + }, + }, + }; + } + if (options.abbr) { + defs.abbrdef = { + pattern: /^ {0,3}\*\[.*?\]:.*$/gm, + inside: { + 'abbr-id': { + pattern: /^ {0,3}\*\[.*?\]/, + inside: { + cl: /(\*\[|\])/, + }, + }, + }, + }; + } + defs.linkdef = { + pattern: /^ {0,3}\[.*?\]:.*$/gm, + inside: { + 'link-id': { + pattern: /^ {0,3}\[.*?\]/, + inside: { + cl: /[[\]]/, + }, + }, + url: urlPattern, + }, + }; + + Object.entries(defs).forEach(([name, def]) => { + grammars.main[name] = def; + grammars.list[name] = def; + grammars.blockquote[name] = def; + grammars.table[name] = def; + grammars.deflist[name] = def; + }); + + grammars.main.pre = { + pattern: /^\s*\n(?: {4}|\t).*\S.*\n((?: {4}|\t).*\n)*/gm, + }; + + const rest = {}; + rest['code cn-code'] = { + pattern: /(`+)[\s\S]*?\1/g, + inside: { + 'cl cl-code': /`/, + }, + }; + if (options.math) { + rest['math block'] = { + pattern: /\\\\\[[\s\S]*?\\\\\]/g, + inside: { + 'cl cl-bracket-start': /^\\\\\[/, + 'cl cl-bracket-end': /\\\\\]$/, + rest: latex, + }, + }; + rest['math inline'] = { + pattern: /\\\\\([\s\S]*?\\\\\)/g, + inside: { + 'cl cl-bracket-start': /^\\\\\(/, + 'cl cl-bracket-end': /\\\\\)$/, + rest: latex, + }, + }; + rest['math expr block'] = { + pattern: /(\$\$)[\s\S]*?\1/g, + inside: { + 'cl cl-bracket-start': /^\$\$/, + 'cl cl-bracket-end': /\$\$$/, + rest: latex, + }, + }; + rest['math expr inline'] = { + pattern: /\$(?!\s)[\s\S]*?\S\$(?!\d)/g, + inside: { + 'cl cl-bracket-start': /^\$/, + 'cl cl-bracket-end': /\$$/, + rest: latex, + }, + }; + } + if (options.footnote) { + rest.inlinefn = { + pattern: /\^\[.+?\]/g, + inside: { + cl: /(\^\[|\])/, + }, + }; + rest.fn = { + pattern: /\[\^.+?\]/g, + inside: { + cl: /(\[\^|\])/, + }, + }; + } + rest.img = { + pattern: /!\[.*?\]\(.+?\)/g, + inside: { + 'cl cl-title': /['‘][^'’]*['’]|["“][^"”]*["”](?=\)$)/, + 'cl cl-src': { + pattern: /(\]\()[^('" \t]+(?=[)'" \t])/, + lookbehind: true, + }, + }, + }; + if (options.imgsize) { + rest.img.inside['cl cl-size'] = /=\d*x\d*/; + } + rest.link = { + pattern: /\[.*?\]\(.+?\)/gm, + inside: { + 'cl cl-underlined-text': { + pattern: /(\[)[^\]]*/, + lookbehind: true, + }, + 'cl cl-title': /['‘][^'’]*['’]|["“][^"”]*["”](?=\)$)/, + }, + }; + rest.imgref = { + pattern: /!\[.*?\][ \t]*\[.*?\]/g, + }; + rest.linkref = { + pattern: /\[.*?\][ \t]*\[.*?\]/g, + inside: { + 'cl cl-underlined-text': { + pattern: /^(\[)[^\]]*(?=\][ \t]*\[)/, + lookbehind: true, + }, + }, + }; + rest.comment = markup.comment; + rest.tag = markup.tag; + rest.url = urlPattern; + rest.email = emailPattern; + rest['strong cn-strong'] = { + pattern: /(^|[^.*])(__|\*\*)(?![_*])[\s\S]*?\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + 'cl cl-strong cl-start': /^(__|\*\*)/, + 'cl cl-strong cl-close': /(__|\*\*)$/, + }, + }; + rest.em = { + pattern: /(^|[^.*])(_|\*)(?![_*])[\s\S]*?\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + 'cl cl-em cl-start': /^(_|\*)/, + 'cl cl-em cl-close': /(_|\*)$/, + }, + }; + rest['strong em'] = { + pattern: /(^|[^.*])(__|\*\*)(_|\*)(?![_*])[\s\S]*?\3\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + 'cl cl-strong cl-start': /^(__|\*\*)(_|\*)/, + 'cl cl-strong cl-close': /(_|\*)(__|\*\*)$/, + }, + }; + rest['strong em inv'] = { + pattern: /(^|[^.*])(_|\*)(__|\*\*)(?![_*])[\s\S]*?\3\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + 'cl cl-strong cl-start': /^(_|\*)(__|\*\*)/, + 'cl cl-strong cl-close': /(__|\*\*)(_|\*)$/, + }, + }; + if (options.del) { + rest.del = { + pattern: /(^|[^.*])(~~)[\s\S]*?\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + cl: /~~/, + 'cl-del-text': /[^~]+/, + }, + }; + } + if (options.mark) { + rest.mark = { + pattern: /(^|[^.*])(==)[\s\S]*?\2(?=([^.*]|$))/gm, + lookbehind: true, + inside: { + cl: /==/, + 'cl-mark-text': /[^=]+/, + }, + }; + } + if (options.sub) { + rest.sub = { + pattern: /(~)(?=\S)(.*?\S)\1/gm, + inside: { + cl: /~/, + }, + }; + } + if (options.sup) { + rest.sup = { + pattern: /(\^)(?=\S)(.*?\S)\1/gm, + inside: { + cl: /\^/, + }, + }; + } + rest.entity = markup.entity; + + for (let c = 6; c >= 1; c -= 1) { + grammars.main[`h${c} cn-head`].inside.rest = rest; + } + grammars.main['h1 alt cn-head'].inside.rest = rest; + grammars.main['h2 alt cn-head'].inside.rest = rest; + grammars.table.table.inside.rest = rest; + grammars.main.rest = rest; + grammars.list.rest = rest; + grammars.blockquote.blockquote.inside.rest = rest; + grammars.deflist.deflist.inside.rest = rest; + if (options.footnote) { + grammars.main.fndef.inside.rest = rest; + } + + const restLight = { + code: rest['code cn-code'], + inlinefn: rest.inlinefn, + fn: rest.fn, + link: rest.link, + linkref: rest.linkref, + }; + rest['strong cn-strong'].inside.rest = restLight; + rest.em.inside.rest = restLight; + if (options.del) { + rest.del.inside.rest = restLight; + } + if (options.mark) { + rest.mark.inside.rest = restLight; + } + + const inside = { + code: rest['code cn-code'], + comment: rest.comment, + tag: rest.tag, + // strong: rest.strong, + strong: rest['strong cn-strong'], + em: rest.em, + del: rest.del, + sub: rest.sub, + sup: rest.sup, + entity: markup.entity, + }; + rest.link.inside['cl cl-underlined-text'].inside = inside; + rest.linkref.inside['cl cl-underlined-text'].inside = inside; + + // Wrap any other characters to allow paragraph folding + Object.entries(grammars).forEach(([, grammar]) => { + grammar.rest = grammar.rest || {}; + grammar.rest.p = /.+/; + }); + + return grammars; + }, +}; diff --git a/src/services/networkSvc.js b/src/services/networkSvc.js new file mode 100644 index 0000000..5fd52f6 --- /dev/null +++ b/src/services/networkSvc.js @@ -0,0 +1,342 @@ +import utils from './utils'; +import store from '../store'; +import constants from '../data/constants'; + +const scriptLoadingPromises = Object.create(null); +const authorizeTimeout = 6 * 60 * 1000; // 2 minutes +const silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattempted) +const networkTimeout = 30 * 1000; // 30 sec +let isConnectionDown = false; +const userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period) +let lastActivity = 0; +let lastFocus = 0; +let isConfLoading = false; +let isConfLoaded = false; + +function parseHeaders(xhr) { + const pairs = xhr.getAllResponseHeaders().trim().split('\n'); + const headers = {}; + pairs.forEach((header) => { + const split = header.trim().split(':'); + const key = split.shift().trim().toLowerCase(); + const value = split.join(':').trim(); + headers[key] = value; + }); + return headers; +} + +function isRetriable(err) { + if (err.status === 403) { + const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason; + return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded'; + } + return err.status === 429 || (err.status >= 500 && err.status < 600); +} + +export default { + async init() { + // Keep track of the last user activity + const setLastActivity = () => { + lastActivity = Date.now(); + }; + window.document.addEventListener('mousedown', setLastActivity); + window.document.addEventListener('keydown', setLastActivity); + window.document.addEventListener('touchstart', setLastActivity); + + // Keep track of the last window focus + lastFocus = 0; + const setLastFocus = () => { + lastFocus = Date.now(); + localStorage.setItem(store.getters['workspace/lastFocusKey'], lastFocus); + setLastActivity(); + }; + if (document.hasFocus()) { + setLastFocus(); + } + window.addEventListener('focus', setLastFocus); + + // Check that browser is online periodically + const checkOffline = async () => { + const isBrowserOffline = window.navigator.onLine === false; + if (!isBrowserOffline + && store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() + && this.isUserActive() + ) { + store.commit('updateLastOfflineCheck'); + const script = document.createElement('script'); + let timeout; + try { + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + script.src = `https://res.wx.qq.com/open/js/jweixin-1.2.0.js?${Date.now()}`; + try { + document.head.appendChild(script); // This can fail with bad network + timeout = setTimeout(reject, networkTimeout); + } catch (e) { + reject(e); + } + }); + isConnectionDown = false; + } catch (e) { + isConnectionDown = true; + } finally { + clearTimeout(timeout); + document.head.removeChild(script); + } + } + const offline = isBrowserOffline || isConnectionDown; + if (store.state.offline !== offline) { + store.commit('setOffline', offline); + if (offline) { + store.dispatch('notification/error', '已离线!'); + } else { + store.dispatch('notification/info', '恢复上线了!'); + this.getServerConf(); + } + } + }; + + utils.setInterval(checkOffline, 1000); + window.addEventListener('online', () => { + isConnectionDown = false; + checkOffline(); + }); + window.addEventListener('offline', checkOffline); + await checkOffline(); + this.getServerConf(); + }, + async getServerConf() { + if (!store.state.offline && !isConfLoading && !isConfLoaded) { + try { + isConfLoading = true; + const res = await this.request({ url: 'conf' }); + await store.dispatch('data/setServerConf', res.body); + isConfLoaded = true; + } finally { + isConfLoading = false; + } + } + }, + isWindowFocused() { + // We don't use state.workspace.lastFocus as it's not reactive + const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']); + return parseInt(storedLastFocus, 10) === lastFocus; + }, + isUserActive() { + return lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused(); + }, + isConfLoaded() { + return !!Object.keys(store.getters['data/serverConf']).length; + }, + async loadScript(url) { + if (!scriptLoadingPromises[url]) { + scriptLoadingPromises[url] = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.onload = resolve; + script.onerror = () => { + scriptLoadingPromises[url] = null; + reject(); + }; + script.src = url; + document.head.appendChild(script); + }); + } + return scriptLoadingPromises[url]; + }, + async startOauth2(url, params = {}, silent = false, reattempt = false) { + try { + // Build the authorize URL + const state = utils.uid(); + const authorizeUrl = utils.addQueryParams(url, { + ...params, + state, + redirect_uri: constants.oauth2RedirectUri, + }); + + let iframeElt; + let wnd; + if (silent) { + // Use an iframe as wnd for silent mode + iframeElt = utils.createHiddenIframe(authorizeUrl); + document.body.appendChild(iframeElt); + wnd = iframeElt.contentWindow; + } else { + // Open a tab otherwise + wnd = window.open(authorizeUrl); + if (!wnd) { + throw new Error('The authorize window was blocked.'); + } + } + + let checkClosedInterval; + let closeTimeout; + let msgHandler; + try { + return await new Promise((resolve, reject) => { + if (silent) { + iframeElt.onerror = () => { + reject(new Error('Unknown error.')); + }; + closeTimeout = setTimeout(() => { + if (!reattempt) { + reject(new Error('REATTEMPT')); + } else { + isConnectionDown = true; + store.commit('setOffline', true); + store.commit('updateLastOfflineCheck'); + reject(new Error('You are offline.')); + } + }, silentAuthorizeTimeout); + } else { + closeTimeout = setTimeout(() => { + reject(new Error('Timeout.')); + }, authorizeTimeout); + } + + msgHandler = (event) => { + if (event.source === wnd && event.origin === constants.origin) { + const data = utils.parseQueryParams(`${event.data}`.slice(1)); + if (data.error || data.state !== state) { + console.error(data); // eslint-disable-line no-console + reject(new Error('Could not get required authorization.')); + } else { + resolve({ + accessToken: data.access_token, + code: data.code, + idToken: data.id_token, + expiresIn: data.expires_in, + }); + } + } + }; + + window.addEventListener('message', msgHandler); + if (!silent) { + checkClosedInterval = setInterval(() => { + if (wnd.closed) { + reject(new Error('Authorize window was closed.')); + } + }, 250); + } + }); + } finally { + clearInterval(checkClosedInterval); + if (!silent && !wnd.closed) { + wnd.close(); + } + if (iframeElt) { + document.body.removeChild(iframeElt); + } + clearTimeout(closeTimeout); + window.removeEventListener('message', msgHandler); + } + } catch (e) { + if (e.message === 'REATTEMPT') { + return this.startOauth2(url, params, silent, true); + } + throw e; + } + }, + async request(config, offlineCheck = false) { + let retryAfter = 500; // 500 ms + const maxRetryAfter = 10 * 1000; // 10 sec + const sanitizedConfig = Object.assign({}, config); + sanitizedConfig.timeout = sanitizedConfig.timeout || networkTimeout; + sanitizedConfig.headers = Object.assign({}, sanitizedConfig.headers); + if (sanitizedConfig.body && typeof sanitizedConfig.body === 'object') { + sanitizedConfig.body = JSON.stringify(sanitizedConfig.body); + sanitizedConfig.headers['Content-Type'] = 'application/json'; + } else if (sanitizedConfig.formData) { + const data = new FormData(); + Object.keys(sanitizedConfig.formData).forEach((key) => { + const formVal = sanitizedConfig.formData[key]; + data.append(key, formVal); + }); + sanitizedConfig.formData = data; + } + const attempt = async () => { + try { + return await new Promise((resolve, reject) => { + if (offlineCheck) { + store.commit('updateLastOfflineCheck'); + } + + const xhr = new window.XMLHttpRequest(); + xhr.withCredentials = sanitizedConfig.withCredentials || false; + + const timeoutId = setTimeout(() => { + xhr.abort(); + if (offlineCheck) { + isConnectionDown = true; + store.commit('setOffline', true); + reject(new Error('You are offline.')); + } else { + reject(new Error('Network request timeout.')); + } + }, sanitizedConfig.timeout); + + xhr.onload = () => { + if (offlineCheck) { + isConnectionDown = false; + } + clearTimeout(timeoutId); + const result = { + status: xhr.status, + headers: parseHeaders(xhr), + body: sanitizedConfig.blob ? xhr.response : xhr.responseText, + }; + if (!sanitizedConfig.raw && !sanitizedConfig.blob) { + try { + result.body = JSON.parse(result.body); + } catch (e) { + // ignore + } + } + if (result.status >= 200 && result.status < 300) { + resolve(result); + } else { + reject(result); + } + }; + + xhr.onerror = () => { + clearTimeout(timeoutId); + if (offlineCheck) { + isConnectionDown = true; + store.commit('setOffline', true); + reject(new Error('You are offline.')); + } else { + reject(new Error('Network request failed.')); + } + }; + + const url = utils.addQueryParams(sanitizedConfig.url, sanitizedConfig.params); + xhr.open(sanitizedConfig.method || 'GET', url); + Object.entries(sanitizedConfig.headers).forEach(([key, value]) => { + if (value) { + xhr.setRequestHeader(key, `${value}`); + } + }); + if (sanitizedConfig.blob) { + xhr.responseType = 'blob'; + } + xhr.send(sanitizedConfig.body || sanitizedConfig.formData || null); + }); + } catch (err) { + // Try again later in case of retriable error + if (isRetriable(err) && retryAfter < maxRetryAfter) { + await new Promise((resolve) => { + setTimeout(resolve, retryAfter); + // Exponential backoff + retryAfter *= 2; + }); + return attempt(); + } + throw err; + } + }; + + return attempt(); + }, +}; diff --git a/src/services/optional/index.js b/src/services/optional/index.js new file mode 100644 index 0000000..e6efbb9 --- /dev/null +++ b/src/services/optional/index.js @@ -0,0 +1,4 @@ +import './shortcuts'; +import './keystrokes'; +import './scrollSync'; +import './taskChange'; diff --git a/src/services/optional/keystrokes.js b/src/services/optional/keystrokes.js new file mode 100644 index 0000000..0d347a4 --- /dev/null +++ b/src/services/optional/keystrokes.js @@ -0,0 +1,188 @@ +import cledit from '../editor/cledit'; +import editorSvc from '../editorSvc'; +import store from '../../store'; + +const { Keystroke } = cledit; +const indentRegexp = /^ {0,3}>[ ]*|^[ \t]*[*+-][ \t](?:\[[ xX]\][ \t])?|^([ \t]*)\d+\.[ \t](?:\[[ xX]\][ \t])?|^\s+/; +let clearNewline; +let lastSelection; + +function fixNumberedList(state, indent) { + if (state.selection + || indent === undefined + || !store.getters['data/computedSettings'].editor.listAutoNumber + ) { + return; + } + const spaceIndent = indent.replace(/\t/g, ' '); + const indentRegex = new RegExp(`^[ \\s]*$|^${spaceIndent}(\\d+\\.[ \\t])?(( )?.*)$`); + + function getHits(lines) { + let hits = []; + let pendingHits = []; + + function flush() { + if (!pendingHits.hasHit && pendingHits.hasNoIndent) { + return false; + } + hits = hits.concat(pendingHits); + pendingHits = []; + return true; + } + + lines.some((line) => { + const match = line.replace( + /^[ \t]*/, + wholeMatch => wholeMatch.replace(/\t/g, ' '), + ).match(indentRegex); + if (!match || line.match(/^#+ /)) { // Line not empty, not indented, or title + flush(); + return true; + } + pendingHits.push({ + line, + match, + }); + if (match[2] !== undefined) { + if (match[1]) { + pendingHits.hasHit = true; + } else if (!match[3]) { + pendingHits.hasNoIndent = true; + } + } else if (!flush()) { + return true; + } + return false; + }); + return hits; + } + + function formatHits(hits) { + let num; + return hits.map((hit) => { + if (hit.match[1]) { + if (!num) { + num = parseInt(hit.match[1], 10); + } + const result = indent + num + hit.match[1].slice(-2) + hit.match[2]; + num += 1; + return result; + } + return hit.line; + }); + } + + const before = state.before.split('\n'); + before.unshift(''); // Add an extra line (fixes #184) + const after = state.after.split('\n'); + let currentLine = before.pop() || ''; + const currentPos = currentLine.length; + currentLine += after.shift() || ''; + let lines = before.concat(currentLine).concat(after); + let idx = before.length - getHits(before.slice().reverse()).length; // Prevents starting from 0 + while (idx <= before.length + 1) { + const hits = formatHits(getHits(lines.slice(idx))); + if (!hits.length) { + idx += 1; + } else { + lines = lines.slice(0, idx).concat(hits).concat(lines.slice(idx + hits.length)); + idx += hits.length; + } + } + currentLine = lines[before.length]; + state.before = lines.slice(1, before.length); // As we've added an extra line + state.before.push(currentLine.slice(0, currentPos)); + state.before = state.before.join('\n'); + state.after = [currentLine.slice(currentPos)].concat(lines.slice(before.length + 1)); + state.after = state.after.join('\n'); +} + +function enterKeyHandler(evt, state) { + if (evt.which !== 13) { + // Not enter + clearNewline = false; + return false; + } + + evt.preventDefault(); + + // Get the last line before the selection + const lastLf = state.before.lastIndexOf('\n') + 1; + const lastLine = state.before.slice(lastLf); + // See if the line is indented + const indentMatch = lastLine.match(indentRegexp) || ['']; + if (clearNewline && !state.selection && state.before.length === lastSelection) { + state.before = state.before.substring(0, lastLf); + state.selection = ''; + clearNewline = false; + fixNumberedList(state, indentMatch[1]); + return true; + } + clearNewline = false; + const indent = indentMatch[0]; + if (indent.length) { + clearNewline = true; + } + + editorSvc.clEditor.undoMgr.setCurrentMode('single'); + + state.before += `\n${indent}`; + state.selection = ''; + lastSelection = state.before.length; + fixNumberedList(state, indentMatch[1]); + return true; +} + +function tabKeyHandler(evt, state) { + if (evt.which !== 9 || evt.metaKey || evt.ctrlKey) { + // Not tab + return false; + } + + const strSplice = (str, i, remove, add) => + str.slice(0, i) + (add || '') + str.slice(i + (+remove || 0)); + + evt.preventDefault(); + const isInverse = evt.shiftKey; + const lastLf = state.before.lastIndexOf('\n') + 1; + const lastLine = state.before.slice(lastLf); + const currentLine = lastLine + state.selection + state.after; + const indentMatch = currentLine.match(indentRegexp); + if (isInverse) { + const previousChar = state.before.slice(-1); + if (/\s/.test(state.before.charAt(lastLf))) { + state.before = strSplice(state.before, lastLf, 1); + if (indentMatch) { + fixNumberedList(state, indentMatch[1]); + if (indentMatch[1]) { + fixNumberedList(state, indentMatch[1].slice(1)); + } + } + } + const selection = previousChar + state.selection; + state.selection = selection.replace(/\n[ \t]/gm, '\n'); + if (previousChar) { + state.selection = state.selection.slice(1); + } + } else if ( + // If selection is not empty + state.selection + // Or we are in an indented paragraph and the cursor is over the indentation characters + || (indentMatch && indentMatch[0].length >= lastLine.length) + ) { + state.before = strSplice(state.before, lastLf, 0, '\t'); + state.selection = state.selection.replace(/\n(?=.)/g, '\n\t'); + if (indentMatch) { + fixNumberedList(state, indentMatch[1]); + fixNumberedList(state, `\t${indentMatch[1]}`); + } + } else { + state.before += '\t'; + } + return true; +} + +editorSvc.$on('inited', () => { + editorSvc.clEditor.addKeystroke(new Keystroke(enterKeyHandler, 50)); + editorSvc.clEditor.addKeystroke(new Keystroke(tabKeyHandler, 50)); +}); diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js new file mode 100644 index 0000000..d997538 --- /dev/null +++ b/src/services/optional/scrollSync.js @@ -0,0 +1,180 @@ +import store from '../../store'; +import animationSvc from '../animationSvc'; +import editorSvc from '../editorSvc'; + +let editorScrollerElt; +let previewScrollerElt; +let editorFinishTimeoutId; +let previewFinishTimeoutId; +let skipAnimation; +let isScrollEditor; +let isScrollPreview; +let isEditorMoving; +let isPreviewMoving; +let sectionDescList = []; + +let throttleTimeoutId; +let throttleLastTime = 0; + +function throttle(func, wait) { + clearTimeout(throttleTimeoutId); + const currentTime = Date.now(); + const localWait = (wait + throttleLastTime) - currentTime; + if (localWait < 1) { + throttleLastTime = currentTime; + func(); + } else { + throttleTimeoutId = setTimeout(() => { + throttleLastTime = Date.now(); + func(); + }, localWait); + } +} + +const doScrollSync = () => { + const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview; + skipAnimation = false; + if (!store.getters['data/layoutSettings'].scrollSync || sectionDescList.length === 0) { + return; + } + let editorScrollTop = editorScrollerElt.scrollTop; + if (editorScrollTop < 0) { + editorScrollTop = 0; + } + const previewScrollTop = previewScrollerElt.scrollTop; + let scrollTo; + if (isScrollEditor) { + // Scroll the preview + isScrollEditor = false; + sectionDescList.some((sectionDesc) => { + if (editorScrollTop > sectionDesc.editorDimension.endOffset) { + return false; + } + const posInSection = (editorScrollTop - sectionDesc.editorDimension.startOffset) + / (sectionDesc.editorDimension.height || 1); + scrollTo = (sectionDesc.previewDimension.startOffset + + (sectionDesc.previewDimension.height * posInSection)); + return true; + }); + scrollTo = Math.min( + scrollTo, + previewScrollerElt.scrollHeight - previewScrollerElt.offsetHeight, + ); + + throttle(() => { + clearTimeout(previewFinishTimeoutId); + animationSvc.animate(previewScrollerElt) + .scrollTop(scrollTo) + .duration(!localSkipAnimation && 100) + .start(() => { + previewFinishTimeoutId = setTimeout(() => { + isPreviewMoving = false; + }, 100); + }, () => { + isPreviewMoving = true; + }); + }, localSkipAnimation ? 500 : 50); + } else if (!store.getters['layout/styles'].showEditor || isScrollPreview) { + // Scroll the editor + isScrollPreview = false; + sectionDescList.some((sectionDesc) => { + if (previewScrollTop > sectionDesc.previewDimension.endOffset) { + return false; + } + const posInSection = (previewScrollTop - sectionDesc.previewDimension.startOffset) + / (sectionDesc.previewDimension.height || 1); + scrollTo = (sectionDesc.editorDimension.startOffset + + (sectionDesc.editorDimension.height * posInSection)); + return true; + }); + scrollTo = Math.min( + scrollTo, + editorScrollerElt.scrollHeight - editorScrollerElt.offsetHeight, + ); + + throttle(() => { + clearTimeout(editorFinishTimeoutId); + animationSvc.animate(editorScrollerElt) + .scrollTop(scrollTo) + .duration(!localSkipAnimation && 100) + .start(() => { + editorFinishTimeoutId = setTimeout(() => { + isEditorMoving = false; + }, 100); + }, () => { + isEditorMoving = true; + }); + }, localSkipAnimation ? 500 : 50); + } +}; + +let isPreviewRefreshing; +let timeoutId; + +const forceScrollSync = () => { + if (!isPreviewRefreshing) { + doScrollSync(); + } +}; +store.watch(() => store.getters['data/layoutSettings'].scrollSync, forceScrollSync); + +editorSvc.$on('inited', () => { + editorScrollerElt = editorSvc.editorElt.parentNode; + previewScrollerElt = editorSvc.previewElt.parentNode; + + editorScrollerElt.addEventListener('scroll', () => { + if (isEditorMoving) { + return; + } + isScrollEditor = true; + isScrollPreview = false; + doScrollSync(); + }); + + previewScrollerElt.addEventListener('scroll', () => { + if (isPreviewMoving || isPreviewRefreshing) { + return; + } + isScrollPreview = true; + isScrollEditor = false; + doScrollSync(); + }); +}); + +editorSvc.$on('sectionList', () => { + clearTimeout(timeoutId); + isPreviewRefreshing = true; + sectionDescList = []; +}); + +editorSvc.$on('previewCtx', () => { + // Assume the user is writing in the editor + isScrollEditor = store.getters['layout/styles'].showEditor; + // A preview scrolling event can occur if height is smaller + timeoutId = setTimeout(() => { + isPreviewRefreshing = false; + }, 100); +}); + +store.watch( + () => store.getters['layout/styles'].showEditor, + (showEditor) => { + isScrollEditor = showEditor; + isScrollPreview = !showEditor; + skipAnimation = true; + }, +); + +store.watch( + () => store.getters['file/current'].id, + () => { + skipAnimation = true; + }, +); + +editorSvc.$on('previewCtxMeasured', (previewCtxMeasured) => { + if (previewCtxMeasured) { + ({ sectionDescList } = previewCtxMeasured); + forceScrollSync(); + } +}); diff --git a/src/services/optional/shortcuts.js b/src/services/optional/shortcuts.js new file mode 100644 index 0000000..5a5c695 --- /dev/null +++ b/src/services/optional/shortcuts.js @@ -0,0 +1,109 @@ +import Mousetrap from 'mousetrap'; +import store from '../../store'; +import editorSvc from '../../services/editorSvc'; +import syncSvc from '../../services/syncSvc'; + +// Skip shortcuts if modal is open +Mousetrap.prototype.stopCallback = () => store.getters['modal/config']; + +const pagedownHandler = name => () => { + editorSvc.pagedownEditor.uiManager.doClick(name); + return true; +}; + +const findReplaceOpener = type => () => { + store.dispatch('findReplace/open', { + type, + findText: editorSvc.clEditor.selectionMgr.hasFocus() && + editorSvc.clEditor.selectionMgr.getSelectedText(), + }); + return true; +}; + +const toggleEditor = () => () => { + store.dispatch('data/toggleEditor', !store.getters['data/layoutSettings'].showEditor); + return true; +}; + +// 非编辑模式下支持的快捷键 +const noEditableShortcutMethods = ['toggleeditor']; + +const methods = { + bold: pagedownHandler('bold'), + italic: pagedownHandler('italic'), + strikethrough: pagedownHandler('strikethrough'), + link: pagedownHandler('link'), + quote: pagedownHandler('quote'), + code: pagedownHandler('code'), + image: pagedownHandler('image'), + chatgpt: pagedownHandler('chatgpt'), + olist: pagedownHandler('olist'), + ulist: pagedownHandler('ulist'), + clist: pagedownHandler('clist'), + heading: pagedownHandler('heading'), + inline: pagedownHandler('heading'), + hr: pagedownHandler('hr'), + inlineformula: pagedownHandler('inlineformula'), + toggleeditor: toggleEditor(), + sync() { + if (syncSvc.isSyncPossible()) { + syncSvc.requestSync(); + } + return true; + }, + find: findReplaceOpener('find'), + replace: findReplaceOpener('replace'), + expand(param1, param2) { + const text = `${param1 || ''}`; + const replacement = `${param2 || ''}`; + if (text && replacement) { + setTimeout(() => { + const { selectionMgr } = editorSvc.clEditor; + let offset = selectionMgr.selectionStart; + if (offset === selectionMgr.selectionEnd) { + const range = selectionMgr.createRange(offset - text.length, offset); + if (`${range}` === text) { + range.deleteContents(); + range.insertNode(document.createTextNode(replacement)); + offset = (offset - text.length) + replacement.length; + selectionMgr.setSelectionStartEnd(offset, offset); + selectionMgr.updateCursorCoordinates(true); + } + } + }, 1); + } + }, +}; + +store.watch( + () => ({ + computedSettings: store.getters['data/computedSettings'], + isCurrentEditable: store.getters['content/isCurrentEditable'], + }), + ({ computedSettings, isCurrentEditable }) => { + Mousetrap.reset(); + + Object.entries(computedSettings.shortcuts).forEach(([key, shortcut]) => { + if (shortcut) { + const method = `${shortcut.method || shortcut}`; + let params = shortcut.params || []; + if (!Array.isArray(params)) { + params = [params]; + } + if (Object.prototype.hasOwnProperty.call(methods, method)) { + try { + // editor is editable or 一些非编辑模式下支持的快捷键 + if (isCurrentEditable || noEditableShortcutMethods.indexOf(method) !== -1) { + Mousetrap.bind(`${key}`, () => !methods[method].apply(null, params)); + } + } catch (e) { + // Ignore + } + } + } + }); + }, + { + immediate: true, + }, +); diff --git a/src/services/optional/taskChange.js b/src/services/optional/taskChange.js new file mode 100644 index 0000000..a575d3b --- /dev/null +++ b/src/services/optional/taskChange.js @@ -0,0 +1,47 @@ +import editorSvc from '../editorSvc'; +import store from '../../store'; + +editorSvc.$on('inited', () => { + const getPreviewOffset = (elt) => { + let offset = 0; + if (!elt || elt === editorSvc.previewElt) { + return offset; + } + let { previousSibling } = elt; + while (previousSibling) { + offset += previousSibling.textContent.length; + ({ previousSibling } = previousSibling); + } + return offset + getPreviewOffset(elt.parentNode); + }; + + editorSvc.previewElt.addEventListener('click', (evt) => { + if (evt.target.classList.contains('task-list-item-checkbox')) { + evt.preventDefault(); + if (store.getters['content/isCurrentEditable']) { + const editorContent = editorSvc.clEditor.getContent(); + // Use setTimeout to ensure evt.target.checked has the old value + setTimeout(() => { + // Make sure content has not changed + if (editorContent === editorSvc.clEditor.getContent()) { + const previewOffset = getPreviewOffset(evt.target); + const endOffset = editorSvc.getEditorOffset(previewOffset + 1); + if (endOffset != null) { + const startOffset = editorContent.lastIndexOf('\n', endOffset) + 1; + const line = editorContent.slice(startOffset, endOffset); + const match = line.match(/^([ \t]*(?:[*+-]|\d+\.)[ \t]+\[)[ xX](\] .*)/); + if (match) { + let newContent = editorContent.slice(0, startOffset); + newContent += match[1]; + newContent += evt.target.checked ? ' ' : 'x'; + newContent += match[2]; + newContent += editorContent.slice(endOffset); + editorSvc.clEditor.setContent(newContent, true); + } + } + } + }, 10); + } + } + }); +}); diff --git a/src/services/providers/bloggerPageProvider.js b/src/services/providers/bloggerPageProvider.js new file mode 100644 index 0000000..8a655be --- /dev/null +++ b/src/services/providers/bloggerPageProvider.js @@ -0,0 +1,45 @@ +import store from '../../store'; +import googleHelper from './helpers/googleHelper'; +import Provider from './common/Provider'; + +export default new Provider({ + id: 'bloggerPage', + name: 'Blogger Page', + getToken({ sub }) { + const token = store.getters['data/googleTokensBySub'][sub]; + return token && token.isBlogger ? token : null; + }, + getLocationUrl({ blogId, pageId }) { + return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=page;pageID=${pageId}`; + }, + getLocationDescription({ pageId }) { + return pageId; + }, + async publish(token, html, metadata, publishLocation) { + const page = await googleHelper.uploadBlogger({ + token, + blogUrl: publishLocation.blogUrl, + blogId: publishLocation.blogId, + postId: publishLocation.pageId, + title: metadata.title, + content: html, + isPage: true, + }); + return { + ...publishLocation, + blogId: page.blog.id, + pageId: page.id, + }; + }, + makeLocation(token, blogUrl, pageId) { + const location = { + providerId: this.id, + sub: token.sub, + blogUrl, + }; + if (pageId) { + location.pageId = pageId; + } + return location; + }, +}); diff --git a/src/services/providers/bloggerProvider.js b/src/services/providers/bloggerProvider.js new file mode 100644 index 0000000..f84d604 --- /dev/null +++ b/src/services/providers/bloggerProvider.js @@ -0,0 +1,45 @@ +import store from '../../store'; +import googleHelper from './helpers/googleHelper'; +import Provider from './common/Provider'; + +export default new Provider({ + id: 'blogger', + name: 'Blogger', + getToken({ sub }) { + const token = store.getters['data/googleTokensBySub'][sub]; + return token && token.isBlogger ? token : null; + }, + getLocationUrl({ blogId, postId }) { + return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=post;postID=${postId}`; + }, + getLocationDescription({ postId }) { + return postId; + }, + async publish(token, html, metadata, publishLocation) { + const post = await googleHelper.uploadBlogger({ + ...publishLocation, + token, + title: metadata.title, + content: html, + labels: metadata.tags, + isDraft: metadata.status === 'draft', + published: metadata.date, + }); + return { + ...publishLocation, + blogId: post.blog.id, + postId: post.id, + }; + }, + makeLocation(token, blogUrl, postId) { + const location = { + providerId: this.id, + sub: token.sub, + blogUrl, + }; + if (postId) { + location.postId = postId; + } + return location; + }, +}); diff --git a/src/services/providers/common/Provider.js b/src/services/providers/common/Provider.js new file mode 100644 index 0000000..1a74aae --- /dev/null +++ b/src/services/providers/common/Provider.js @@ -0,0 +1,102 @@ +import providerRegistry from './providerRegistry'; +import emptyContent from '../../../data/empties/emptyContent'; +import utils from '../../utils'; +import store from '../../../store'; +import workspaceSvc from '../../workspaceSvc'; + +const dataExtractor = /\s*$/; + +export default class Provider { + prepareChanges = changes => changes + onChangesApplied = () => {} + + constructor(props) { + Object.assign(this, props); + providerRegistry.register(this); + } + + /** + * Serialize content in a self contain Markdown compatible format + */ + static serializeContent(content) { + let result = content.text; + const data = {}; + if (content.properties.length > 1) { + data.properties = content.properties; + } + if (Object.keys(content.discussions).length) { + data.discussions = content.discussions; + } + if (Object.keys(content.comments).length) { + data.comments = content.comments; + } + if (content.history && content.history.length) { + data.history = content.history; + } + if (Object.keys(data).length) { + const serializedData = utils.encodeBase64(JSON.stringify(data)).replace(/(.{50})/g, '$1\n'); + result += ``; + } + return result; + } + + /** + * Parse content serialized with serializeContent() + */ + static parseContent(serializedContent, id) { + let text = serializedContent; + const extractedData = dataExtractor.exec(serializedContent); + let result; + if (!extractedData) { + // In case stackedit's data has been manually removed, try to restore them + result = utils.deepCopy(store.state.content.itemsById[id]) || emptyContent(id); + } else { + result = emptyContent(id); + try { + const serializedData = extractedData[1].replace(/\s/g, ''); + const parsedData = JSON.parse(utils.decodeBase64(serializedData)); + text = text.slice(0, extractedData.index); + if (parsedData.properties) { + result.properties = utils.sanitizeText(parsedData.properties); + } + if (parsedData.discussions) { + result.discussions = parsedData.discussions; + } + if (parsedData.comments) { + result.comments = parsedData.comments; + } + result.history = parsedData.history; + } catch (e) { + // Ignore + } + } + result.text = utils.sanitizeText(text); + if (!result.history) { + result.history = []; + } + return utils.addItemHash(result); + } + + /** + * Find and open a file with location that meets the criteria + */ + static openFileWithLocation(criteria) { + const location = utils.search(store.getters['syncLocation/items'], criteria); + if (location) { + // Found one, open it if it exists + const item = store.state.file.itemsById[location.fileId]; + if (item) { + store.commit('file/setCurrentId', item.id); + // If file is in the trash, restore it + if (item.parentId === 'trash') { + workspaceSvc.setOrPatchItem({ + ...item, + parentId: null, + }); + } + return true; + } + } + return false; + } +} diff --git a/src/services/providers/common/providerRegistry.js b/src/services/providers/common/providerRegistry.js new file mode 100644 index 0000000..d3b9021 --- /dev/null +++ b/src/services/providers/common/providerRegistry.js @@ -0,0 +1,7 @@ +export default { + providersById: {}, + register(provider) { + this.providersById[provider.id] = provider; + return provider; + }, +}; diff --git a/src/services/providers/couchdbWorkspaceProvider.js b/src/services/providers/couchdbWorkspaceProvider.js new file mode 100644 index 0000000..e8af418 --- /dev/null +++ b/src/services/providers/couchdbWorkspaceProvider.js @@ -0,0 +1,229 @@ +import store from '../../store'; +import couchdbHelper from './helpers/couchdbHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import badgeSvc from '../badgeSvc'; + +let syncLastSeq; + +export default new Provider({ + id: 'couchdbWorkspace', + name: 'CouchDB', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ dbUrl }) { + return { + providerId: this.id, + dbUrl, + }; + }, + getWorkspaceLocationUrl({ dbUrl }) { + return dbUrl; + }, + getSyncDataUrl(fileSyncData, { id }) { + const { dbUrl } = this.getToken(); + return `${dbUrl}/${id}/data`; + }, + getSyncDataDescription(fileSyncData, { id }) { + return id; + }, + async initWorkspace() { + const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing / + const workspaceParams = this.getWorkspaceParams({ dbUrl }); + const workspaceId = utils.makeWorkspaceId(workspaceParams); + + // Create the token if it doesn't exist + if (!store.getters['data/couchdbTokensBySub'][workspaceId]) { + store.dispatch('data/addCouchdbToken', { + sub: workspaceId, + dbUrl, + }); + } + + // Create the workspace if it doesn't exist + if (!store.getters['workspace/workspacesById'][workspaceId]) { + try { + // Make sure the database exists and retrieve its name + const db = await couchdbHelper.getDb(store.getters['data/couchdbTokensBySub'][workspaceId]); + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + id: workspaceId, + name: db.db_name, + providerId: this.id, + dbUrl, + }, + }); + } catch (e) { + throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`); + } + } + + badgeSvc.addBadge('addCouchdbWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + async getChanges() { + const syncToken = store.getters['workspace/syncToken']; + const lastSeq = store.getters['data/localSettings'].syncLastSeq; + const result = await couchdbHelper.getChanges(syncToken, lastSeq); + const changes = result.changes.filter((change) => { + if (!change.deleted && change.doc) { + change.item = change.doc.item; + if (!change.item || !change.item.id || !change.item.type) { + return false; + } + // Build sync data + change.syncData = { + id: change.id, + itemId: change.item.id, + type: change.item.type, + hash: change.item.hash, + rev: change.doc._rev, // eslint-disable-line no-underscore-dangle + }; + } + change.syncDataId = change.id; + return true; + }); + syncLastSeq = result.lastSeq; + return changes; + }, + onChangesApplied() { + store.dispatch('data/patchLocalSettings', { + syncLastSeq, + }); + }, + async saveWorkspaceItem({ item, syncData }) { + const syncToken = store.getters['workspace/syncToken']; + const { id, rev } = await couchdbHelper.uploadDocument({ + token: syncToken, + item, + documentId: syncData && syncData.id, + rev: syncData && syncData.rev, + }); + + // Build sync data to save + return { + syncData: { + id, + itemId: item.id, + type: item.type, + hash: item.hash, + rev, + }, + }; + }, + removeWorkspaceItem({ syncData }) { + const syncToken = store.getters['workspace/syncToken']; + return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev); + }, + async downloadWorkspaceContent({ token, contentSyncData }) { + const body = await couchdbHelper.retrieveDocumentWithAttachments(token, contentSyncData.id); + const rev = body._rev; // eslint-disable-line no-underscore-dangle + const content = Provider.parseContent(body.attachments.data, body.item.id); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + rev, + }, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id); + const item = utils.addItemHash(JSON.parse(body.attachments.data)); + const rev = body._rev; // eslint-disable-line no-underscore-dangle + return { + item, + syncData: { + ...syncData, + hash: item.hash, + rev, + }, + }; + }, + async uploadWorkspaceContent({ token, content, contentSyncData }) { + const res = await couchdbHelper.uploadDocument({ + token, + item: { + id: content.id, + type: content.type, + hash: content.hash, + }, + data: Provider.serializeContent(content), + dataType: 'text/plain', + documentId: contentSyncData && contentSyncData.id, + rev: contentSyncData && contentSyncData.rev, + }); + + // Return new sync data + return { + contentSyncData: { + id: res.id, + itemId: content.id, + type: content.type, + hash: content.hash, + rev: res.rev, + }, + }; + }, + async uploadWorkspaceData({ token, item, syncData }) { + const res = await couchdbHelper.uploadDocument({ + token, + item: { + id: item.id, + type: item.type, + hash: item.hash, + }, + data: JSON.stringify(item), + dataType: 'application/json', + documentId: syncData && syncData.id, + rev: syncData && syncData.rev, + }); + + // Return new sync data + return { + syncData: { + id: res.id, + itemId: item.id, + type: item.type, + hash: item.hash, + rev: res.rev, + }, + }; + }, + async listFileRevisions({ token, contentSyncDataId }) { + const body = await couchdbHelper.retrieveDocumentWithRevisions(token, contentSyncDataId); + const revisions = []; + body._revs_info.forEach((revInfo, idx) => { // eslint-disable-line no-underscore-dangle + if (revInfo.status === 'available') { + revisions.push({ + id: revInfo.rev, + sub: null, + created: idx, + loaded: false, + }); + } + }); + return revisions; + }, + async loadFileRevision({ token, contentSyncDataId, revision }) { + if (revision.loaded) { + return false; + } + const body = await couchdbHelper.retrieveDocument(token, contentSyncDataId, revision.id); + revision.sub = body.sub; + revision.created = body.time; + revision.loaded = true; + return true; + }, + async getFileRevisionContent({ token, contentSyncDataId, revisionId }) { + const body = await couchdbHelper + .retrieveDocumentWithAttachments(token, contentSyncDataId, revisionId); + return Provider.parseContent(body.attachments.data, body.item.id); + }, +}); diff --git a/src/services/providers/dropboxProvider.js b/src/services/providers/dropboxProvider.js new file mode 100644 index 0000000..9d3ac2b --- /dev/null +++ b/src/services/providers/dropboxProvider.js @@ -0,0 +1,153 @@ +import store from '../../store'; +import dropboxHelper from './helpers/dropboxHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; + +const makePathAbsolute = (token, path) => { + if (!token.fullAccess) { + return `/Applications/StackEdit (restricted)${path}`; + } + return path; +}; +const makePathRelative = (token, path) => { + if (!token.fullAccess) { + return path.replace(/^\/Applications\/StackEdit \(restricted\)/, ''); + } + return path; +}; + +export default new Provider({ + id: 'dropbox', + name: 'Dropbox', + getToken({ sub }) { + return store.getters['data/dropboxTokensBySub'][sub]; + }, + getLocationUrl({ path }) { + const pathComponents = path.split('/').map(encodeURIComponent); + const filename = pathComponents.pop(); + return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`; + }, + getLocationDescription({ path, dropboxFileId }) { + return dropboxFileId || path; + }, + checkPath(path) { + return path && path.match(/^\/[^\\<>:"|?*]+$/); + }, + async downloadContent(token, syncLocation) { + const { content } = await dropboxHelper.downloadFile({ + token, + path: makePathRelative(token, syncLocation.path), + fileId: syncLocation.dropboxFileId, + }); + return Provider.parseContent(content, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const dropboxFile = await dropboxHelper.uploadFile({ + token, + path: makePathRelative(token, syncLocation.path), + content: Provider.serializeContent(content), + fileId: syncLocation.dropboxFileId, + }); + return { + ...syncLocation, + path: makePathAbsolute(token, dropboxFile.path_display), + dropboxFileId: dropboxFile.id, + }; + }, + async publish(token, html, metadata, publishLocation) { + const dropboxFile = await dropboxHelper.uploadFile({ + token, + path: publishLocation.path, + content: html, + fileId: publishLocation.dropboxFileId, + }); + return { + ...publishLocation, + path: makePathAbsolute(token, dropboxFile.path_display), + dropboxFileId: dropboxFile.id, + }; + }, + async openFiles(token, paths) { + await utils.awaitSequence(paths, async (path) => { + // Check if the file exists and open it + if (!Provider.openFileWithLocation({ + providerId: this.id, + path, + })) { + // Download content from Dropbox + const syncLocation = { + path, + providerId: this.id, + sub: token.sub, + }; + let content; + try { + content = await this.downloadContent(token, syncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${path}.`); + return; + } + + // Create the file + let name = path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...syncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从Dropbox导入。`); + } + }); + }, + makeLocation(token, path) { + return { + providerId: this.id, + sub: token.sub, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await dropboxHelper.listRevisions({ + token, + path: makePathRelative(token, syncLocation.path), + fileId: syncLocation.dropboxFileId, + }); + return entries.map(entry => ({ + id: entry.rev, + sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`, + created: new Date(entry.server_modified).getTime(), + })); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + revisionId, + }) { + const { content } = await dropboxHelper.downloadFile({ + token, + path: `rev:${revisionId}`, + }); + return Provider.parseContent(content, contentId); + }, +}); diff --git a/src/services/providers/gistProvider.js b/src/services/providers/gistProvider.js new file mode 100644 index 0000000..b4ac7f7 --- /dev/null +++ b/src/services/providers/gistProvider.js @@ -0,0 +1,95 @@ +import store from '../../store'; +import githubHelper from './helpers/githubHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; + +export default new Provider({ + id: 'gist', + name: 'Gist', + getToken({ sub }) { + return store.getters['data/githubTokensBySub'][sub]; + }, + getLocationUrl({ gistId }) { + return `https://gist.github.com/${gistId}`; + }, + getLocationDescription({ filename }) { + return filename; + }, + async downloadContent(token, syncLocation) { + const content = await githubHelper.downloadGist({ + ...syncLocation, + token, + }); + return Provider.parseContent(content, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const file = store.state.file.itemsById[syncLocation.fileId]; + const description = utils.sanitizeName(file && file.name); + const gist = await githubHelper.uploadGist({ + ...syncLocation, + token, + description, + content: Provider.serializeContent(content), + }); + return { + ...syncLocation, + gistId: gist.id, + }; + }, + async publish(token, html, metadata, publishLocation) { + const gist = await githubHelper.uploadGist({ + ...publishLocation, + token, + description: metadata.title, + content: html, + }); + return { + ...publishLocation, + gistId: gist.id, + }; + }, + makeLocation(token, filename, isPublic, gistId) { + return { + providerId: this.id, + sub: token.sub, + filename, + isPublic, + gistId, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await githubHelper.getGistCommits({ + ...syncLocation, + token, + }); + + return entries.map((entry) => { + const sub = `${githubHelper.subPrefix}:${entry.user.id}`; + userSvc.addUserInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url }); + return { + sub, + id: entry.version, + message: entry.commit && entry.commit.message, + created: new Date(entry.committed_at).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const data = await githubHelper.downloadGistRevision({ + ...syncLocation, + token, + sha: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/giteaProvider.js b/src/services/providers/giteaProvider.js new file mode 100644 index 0000000..063eb90 --- /dev/null +++ b/src/services/providers/giteaProvider.js @@ -0,0 +1,180 @@ +import store from '../../store'; +import giteaHelper from './helpers/giteaHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'gitea', + name: 'Gitea', + getToken({ sub }) { + return store.getters['data/giteaTokensBySub'][sub]; + }, + getLocationUrl({ + sub, + projectPath, + branch, + path, + }) { + const token = this.getToken({ sub }); + return `${token.serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await giteaHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await giteaHelper.getProjectId(token, syncLocation), + }; + if (!savedSha[updatedSyncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[updatedSyncLocation.id]; + delete savedSha[updatedSyncLocation.id]; + await giteaHelper.uploadFile({ + ...updatedSyncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return updatedSyncLocation; + }, + async publish(token, html, metadata, publishLocation, commitMessage) { + const updatedPublishLocation = { + ...publishLocation, + projectId: await giteaHelper.getProjectId(token, publishLocation), + }; + try { + // Get the last sha + await this.downloadContent(token, updatedPublishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[updatedPublishLocation.id]; + delete savedSha[updatedPublishLocation.id]; + await giteaHelper.uploadFile({ + ...updatedPublishLocation, + token, + content: html, + sha, + commitMessage, + }); + return updatedPublishLocation; + }, + async openFile(token, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await giteaHelper.getProjectId(token, syncLocation), + }; + + // Check if the file exists and open it + if (!Provider.openFileWithLocation(updatedSyncLocation)) { + // Download content from Gitea + let content; + try { + content = await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`); + return; + } + + // Create the file + let name = updatedSyncLocation.path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...updatedSyncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从Gitea导入。`); + } + }, + makeLocation(token, projectPath, branch, path) { + return { + providerId: this.id, + sub: token.sub, + projectPath, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await giteaHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${giteaHelper.subPrefix}:${user.login}`; + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date); + return { + id: sha, + sub, + message: commit.message, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await giteaHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/giteaWorkspaceProvider.js b/src/services/providers/giteaWorkspaceProvider.js new file mode 100644 index 0000000..e859067 --- /dev/null +++ b/src/services/providers/giteaWorkspaceProvider.js @@ -0,0 +1,331 @@ +import store from '../../store'; +import giteaHelper from './helpers/giteaHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import badgeSvc from '../badgeSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'giteaWorkspace', + name: 'Gitea', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + serverUrl, + projectPath, + branch, + path, + }) { + return { + providerId: this.id, + serverUrl, + projectPath, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + serverUrl, + projectPath, + branch, + path, + }) { + return `${serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { projectPath, branch } = store.getters['workspace/currentWorkspace']; + const { serverUrl } = this.getToken(); + return `${serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { serverUrl, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ serverUrl, branch }); + if (!branch) { + workspaceParams.branch = 'master'; + } + + // Extract project path param + const projectPath = (utils.queryParams.projectPath || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, ''); // Remove trailing `/` + workspaceParams.projectPath = projectPath; + + // Extract path param + const path = (utils.queryParams.path || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + const sub = workspace ? workspace.sub : utils.queryParams.sub; + let token = store.getters['data/giteaTokensBySub'][sub]; + if (!token) { + const applicationInfo = await store.dispatch('modal/open', { + type: 'giteaAccount', + forceServerUrl: serverUrl, + }); + token = await giteaHelper.addAccount(applicationInfo, sub); + } + + if (!workspace) { + const projectId = await giteaHelper.getProjectId(token, workspaceParams); + const pathEntries = (path || '').split('/'); + const projectPathEntries = (projectPath || '').split('/'); + const name = pathEntries[pathEntries.length - 2] // path ends with `/` + || projectPathEntries[projectPathEntries.length - 1]; + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + projectId, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + badgeSvc.addBadge('addGiteaWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return giteaHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree.tree.map(entry => ({ + ...entry, + id: entry.sha, + }))); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await giteaHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = store.getters.gitPathsByItemId[file.id] || ''; + const absolutePath = !isImg ? `${store.getters['workspace/currentWorkspace'].path || ''}${store.getters.gitPathsByItemId[file.id]}` : file.path; + const sha = gitWorkspaceSvc.shaByPath[!isImg ? path : file.path]; + const res = await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha, + isImg, + commitMessage, + }); + + if (isImg) { + return { + sha: res.content.sha, + }; + } + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { projectId, branch } = store.getters['workspace/currentWorkspace']; + const entries = await giteaHelper.getCommits({ + token, + projectId, + sha: branch, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${giteaHelper.subPrefix}:${user.login}`; + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date) + || 1; + return { + id: sha, + sub, + message: commit.message, + created: new Date(date).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + return Provider.parseContent(data, contentId); + }, + getFilePathUrl(path) { + const token = this.getToken(); + if (!token) { + return null; + } + const workspace = store.getters['workspace/currentWorkspace']; + return `${token.serverUrl}/${workspace.owner}/${workspace.repo}/src/branch/${workspace.branch}${path}`; + }, +}); diff --git a/src/services/providers/giteeAppDataProvider.js b/src/services/providers/giteeAppDataProvider.js new file mode 100644 index 0000000..0e9c815 --- /dev/null +++ b/src/services/providers/giteeAppDataProvider.js @@ -0,0 +1,292 @@ +import store from '../../store'; +import giteeHelper from './helpers/giteeHelper'; +import Provider from './common/Provider'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import userSvc from '../userSvc'; + +const appDataRepo = 'stackedit-app-data'; +const appDataBranch = 'master'; + +export default new Provider({ + id: 'giteeAppData', + name: 'Gitee应用数据', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams() { + // No param as it's the main workspace + return {}; + }, + getWorkspaceLocationUrl() { + // No direct link to app data + return null; + }, + getSyncDataUrl() { + // No direct link to app data + return null; + }, + getSyncDataDescription({ id }) { + return id; + }, + async initWorkspace() { + // Nothing much to do since the main workspace isn't necessarily synchronized + // Return the main workspace + return store.getters['workspace/workspacesById'].main; + }, + getChanges() { + const token = this.getToken(); + return giteeHelper.getTree({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await giteeHelper.uploadFile({ + owner: syncToken.name, + repo: appDataRepo, + branch: appDataBranch, + token: syncToken, + path: syncData.id, + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await giteeHelper.removeFile({ + owner: syncToken.name, + repo: appDataRepo, + branch: appDataBranch, + token: syncToken, + path: syncData.id, + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await giteeHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path: fileSyncData.id, + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await giteeHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + const path = `.stackedit-data/${syncData.id}.json`; + // const path = store.getters.gitPathsByItemId[syncData.id]; + // const path = syncData.id; + const { sha, data } = await giteeHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + }); + if (!sha) { + return {}; + } + gitWorkspaceSvc.shaByPath[path] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + type: 'data', + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = !isImg ? store.getters.gitPathsByItemId[file.id] : file.path; + const res = await giteeHelper.uploadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha: gitWorkspaceSvc.shaByPath[!isImg ? path : file.path], + isImg, + commitMessage, + }); + + if (isImg) { + return { + sha: res.content.sha, + }; + } + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ + token, + item, + syncData, + }) { + const path = `.stackedit-data/${item.id}.json`; + // const path = store.getters.gitPathsByItemId[item.id]; + // const path = syncData.id; + const res = await giteeHelper.uploadFile({ + token, + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + path, + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + type: item.type, + hash: item.hash, + data: item.data, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { owner, repo, branch } = { + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + }; + const entries = await giteeHelper.getCommits({ + token, + owner, + repo, + sha: branch, + path: fileSyncDataId, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${giteeHelper.subPrefix}:${user.login}`; + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date) + || 1; + return { + id: sha, + sub, + message: commit.message, + created: new Date(date).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await giteeHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: revisionId, + token, + path: fileSyncDataId, + }); + return Provider.parseContent(data, contentId); + }, + getFilePathUrl(path) { + const token = this.getToken(); + if (!token) { + return null; + } + return `https://gitee.com/${token.name}/${appDataRepo}/blob/${appDataBranch}${path}`; + }, +}); diff --git a/src/services/providers/giteeGistProvider.js b/src/services/providers/giteeGistProvider.js new file mode 100644 index 0000000..eb05b96 --- /dev/null +++ b/src/services/providers/giteeGistProvider.js @@ -0,0 +1,95 @@ +import store from '../../store'; +import giteeHelper from './helpers/giteeHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; + +export default new Provider({ + id: 'giteegist', + name: 'GiteeGist', + getToken({ sub }) { + return store.getters['data/giteeTokensBySub'][sub]; + }, + getLocationUrl({ gistId }) { + return `https://gitee.com/mafgwo/codes/${gistId}`; + }, + getLocationDescription({ filename }) { + return filename; + }, + async downloadContent(token, syncLocation) { + const content = await giteeHelper.downloadGist({ + ...syncLocation, + token, + }); + return Provider.parseContent(content, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const file = store.state.file.itemsById[syncLocation.fileId]; + const description = utils.sanitizeName(file && file.name); + const gist = await giteeHelper.uploadGist({ + ...syncLocation, + token, + description, + content: Provider.serializeContent(content), + }); + return { + ...syncLocation, + gistId: gist.id, + }; + }, + async publish(token, html, metadata, publishLocation) { + const gist = await giteeHelper.uploadGist({ + ...publishLocation, + token, + description: metadata.title, + content: html, + }); + return { + ...publishLocation, + gistId: gist.id, + }; + }, + makeLocation(token, filename, isPublic, gistId) { + return { + providerId: this.id, + sub: token.sub, + filename, + isPublic, + gistId, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await giteeHelper.getGistCommits({ + ...syncLocation, + token, + }); + + return entries.map((entry) => { + const sub = `${giteeHelper.subPrefix}:${entry.user.id}`; + userSvc.addUserInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url }); + return { + sub, + id: entry.version, + message: entry.commit && entry.commit.message, + created: new Date(entry.committed_at).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + // async getFileRevisionContent({ + // token, + // contentId, + // syncLocation, + // revisionId, + // }) { + // const data = await giteeHelper.downloadGistRevision({ + // ...syncLocation, + // token, + // sha: revisionId, + // }); + // return Provider.parseContent(data, contentId); + // }, +}); diff --git a/src/services/providers/giteeProvider.js b/src/services/providers/giteeProvider.js new file mode 100644 index 0000000..abcaa09 --- /dev/null +++ b/src/services/providers/giteeProvider.js @@ -0,0 +1,170 @@ +import store from '../../store'; +import giteeHelper from './helpers/giteeHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'gitee', + name: 'Gitee', + getToken({ sub }) { + return store.getters['data/giteeTokensBySub'][sub]; + }, + getLocationUrl({ + owner, + repo, + branch, + path, + }) { + return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await giteeHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + if (!savedSha[syncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, syncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[syncLocation.id]; + delete savedSha[syncLocation.id]; + await giteeHelper.uploadFile({ + ...syncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return syncLocation; + }, + async publish(token, html, metadata, publishLocation, commitMessage) { + try { + // Get the last sha + await this.downloadContent(token, publishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[publishLocation.id]; + delete savedSha[publishLocation.id]; + await giteeHelper.uploadFile({ + ...publishLocation, + token, + content: html, + sha, + commitMessage, + }); + return publishLocation; + }, + async openFile(token, syncLocation) { + // Check if the file exists and open it + if (!Provider.openFileWithLocation(syncLocation)) { + // Download content from Gitee + let content; + try { + content = await this.downloadContent(token, syncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`); + return; + } + + // Create the file + let name = syncLocation.path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...syncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从Gitee导入。`); + } + }, + makeLocation(token, owner, repo, branch, path) { + return { + providerId: this.id, + sub: token.sub, + owner, + repo, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await giteeHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${giteeHelper.subPrefix}:${user.login}`; + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date); + return { + id: sha, + sub, + message: commit.message, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await giteeHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/giteeWorkspaceProvider.js b/src/services/providers/giteeWorkspaceProvider.js new file mode 100644 index 0000000..86dc680 --- /dev/null +++ b/src/services/providers/giteeWorkspaceProvider.js @@ -0,0 +1,315 @@ +import store from '../../store'; +import giteeHelper from './helpers/giteeHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import badgeSvc from '../badgeSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'giteeWorkspace', + name: 'Gitee', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + owner, + repo, + branch, + path, + }) { + return { + providerId: this.id, + owner, + repo, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + owner, + repo, + branch, + path, + }) { + return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; + return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { owner, repo, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ owner, repo, branch }); + if (!branch) { + workspaceParams.branch = 'master'; + } + + // Extract path param + const path = (utils.queryParams.path || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + let token; + if (workspace) { + // Token sub is in the workspace + token = store.getters['data/giteeTokensBySub'][workspace.sub]; + } + if (!token) { + await store.dispatch('modal/open', { type: 'giteeAccount' }); + token = await giteeHelper.addAccount(); + } + + if (!workspace) { + const pathEntries = (path || '').split('/'); + const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/` + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + badgeSvc.addBadge('addGiteeWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return giteeHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await giteeHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await giteeHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await giteeHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await giteeHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await giteeHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = store.getters.gitPathsByItemId[file.id] || ''; + const absolutePath = !isImg ? `${store.getters['workspace/currentWorkspace'].path || ''}${path}` : file.path; + const res = await giteeHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha: gitWorkspaceSvc.shaByPath[!isImg ? path : file.path], + isImg, + commitMessage, + }); + + if (isImg) { + return { + sha: res.content.sha, + }; + } + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await giteeHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; + const entries = await giteeHelper.getCommits({ + token, + owner, + repo, + sha: branch, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${giteeHelper.subPrefix}:${user.login}`; + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date) + || 1; + return { + id: sha, + sub, + message: commit.message, + created: new Date(date).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await giteeHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + return Provider.parseContent(data, contentId); + }, + getFilePathUrl(path) { + const workspace = store.getters['workspace/currentWorkspace']; + return `https://gitee.com/${workspace.owner}/${workspace.repo}/blob/${workspace.branch}${path}`; + }, +}); diff --git a/src/services/providers/githubAppDataProvider.js b/src/services/providers/githubAppDataProvider.js new file mode 100644 index 0000000..f774b20 --- /dev/null +++ b/src/services/providers/githubAppDataProvider.js @@ -0,0 +1,292 @@ +import store from '../../store'; +import githubHelper from './helpers/githubHelper'; +import Provider from './common/Provider'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import userSvc from '../userSvc'; + +const appDataRepo = 'stackedit-app-data'; +const appDataBranch = 'master'; + +export default new Provider({ + id: 'githubAppData', + name: 'Gitee应用数据', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams() { + // No param as it's the main workspace + return {}; + }, + getWorkspaceLocationUrl() { + // No direct link to app data + return null; + }, + getSyncDataUrl() { + // No direct link to app data + return null; + }, + getSyncDataDescription({ id }) { + return id; + }, + async initWorkspace() { + // Nothing much to do since the main workspace isn't necessarily synchronized + // Return the main workspace + return store.getters['workspace/workspacesById'].main; + }, + getChanges() { + const token = this.getToken(); + return githubHelper.getTree({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await githubHelper.uploadFile({ + owner: syncToken.name, + repo: appDataRepo, + branch: appDataBranch, + token: syncToken, + path: syncData.id, + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await githubHelper.removeFile({ + owner: syncToken.name, + repo: appDataRepo, + branch: appDataBranch, + token: syncToken, + path: syncData.id, + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await githubHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path: fileSyncData.id, + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await githubHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + const path = `.stackedit-data/${syncData.id}.json`; + // const path = store.getters.gitPathsByItemId[syncData.id]; + // const path = syncData.id; + const { sha, data } = await githubHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + }); + if (!sha) { + return {}; + } + gitWorkspaceSvc.shaByPath[path] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + type: 'data', + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = !isImg ? store.getters.gitPathsByItemId[file.id] : file.path; + const res = await githubHelper.uploadFile({ + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + token, + path, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha: gitWorkspaceSvc.shaByPath[!isImg ? path : file.path], + isImg, + commitMessage, + }); + + if (isImg) { + return { + sha: res.content.sha, + }; + } + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ + token, + item, + syncData, + }) { + const path = `.stackedit-data/${item.id}.json`; + // const path = store.getters.gitPathsByItemId[item.id]; + // const path = syncData.id; + const res = await githubHelper.uploadFile({ + token, + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + path, + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + type: item.type, + hash: item.hash, + data: item.data, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { owner, repo, branch } = { + owner: token.name, + repo: appDataRepo, + branch: appDataBranch, + }; + const entries = await githubHelper.getCommits({ + token, + owner, + repo, + sha: branch, + path: fileSyncDataId, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${githubHelper.subPrefix}:${user.login}`; + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date) + || 1; + return { + id: sha, + sub, + message: commit.message, + created: new Date(date).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await githubHelper.downloadFile({ + owner: token.name, + repo: appDataRepo, + branch: revisionId, + token, + path: fileSyncDataId, + }); + return Provider.parseContent(data, contentId); + }, + getFilePathUrl(path) { + const token = this.getToken(); + if (!token) { + return null; + } + return `https://github.com/${token.name}/${appDataRepo}/blob/${appDataBranch}${path}`; + }, +}); diff --git a/src/services/providers/githubProvider.js b/src/services/providers/githubProvider.js new file mode 100644 index 0000000..d0f32bc --- /dev/null +++ b/src/services/providers/githubProvider.js @@ -0,0 +1,169 @@ +import store from '../../store'; +import githubHelper from './helpers/githubHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'github', + name: 'GitHub', + getToken({ sub }) { + return store.getters['data/githubTokensBySub'][sub]; + }, + getLocationUrl({ + owner, + repo, + branch, + path, + }) { + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await githubHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + if (!savedSha[syncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, syncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[syncLocation.id]; + delete savedSha[syncLocation.id]; + await githubHelper.uploadFile({ + ...syncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return syncLocation; + }, + async publish(token, html, metadata, publishLocation, commitMessage) { + try { + // Get the last sha + await this.downloadContent(token, publishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[publishLocation.id]; + delete savedSha[publishLocation.id]; + await githubHelper.uploadFile({ + ...publishLocation, + token, + content: html, + sha, + commitMessage, + }); + return publishLocation; + }, + async openFile(token, syncLocation) { + // Check if the file exists and open it + if (!Provider.openFileWithLocation(syncLocation)) { + // Download content from GitHub + let content; + try { + content = await this.downloadContent(token, syncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`); + return; + } + + // Create the file + let name = syncLocation.path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...syncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从GitHub导入。`); + } + }, + makeLocation(token, owner, repo, branch, path) { + return { + providerId: this.id, + sub: token.sub, + owner, + repo, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await githubHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } else if (commit && commit.author) { + user = commit.author; + } + const sub = `${githubHelper.subPrefix}:${user.id || user.name}`; + userSvc.addUserInfo({ id: sub, name: user.login || user.name, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date); + return { + id: sha, + sub, + message: commit.message, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await githubHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/githubWorkspaceProvider.js b/src/services/providers/githubWorkspaceProvider.js new file mode 100644 index 0000000..84e465f --- /dev/null +++ b/src/services/providers/githubWorkspaceProvider.js @@ -0,0 +1,313 @@ +import store from '../../store'; +import githubHelper from './helpers/githubHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import badgeSvc from '../badgeSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'githubWorkspace', + name: 'GitHub', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + owner, + repo, + branch, + path, + }) { + return { + providerId: this.id, + owner, + repo, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + owner, + repo, + branch, + path, + }) { + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { owner, repo, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ owner, repo, branch }); + if (!branch) { + workspaceParams.branch = 'master'; + } + + // Extract path param + const path = (utils.queryParams.path || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + let token; + if (workspace) { + // Token sub is in the workspace + token = store.getters['data/githubTokensBySub'][workspace.sub]; + } + if (!token) { + await store.dispatch('modal/open', { type: 'githubAccount' }); + token = await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess); + } + + if (!workspace) { + const pathEntries = (path || '').split('/'); + const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/` + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + badgeSvc.addBadge('addGithubWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return githubHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await githubHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await githubHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await githubHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await githubHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await githubHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = store.getters.gitPathsByItemId[file.id] || ''; + const absolutePath = !isImg ? `${store.getters['workspace/currentWorkspace'].path || ''}${path}` : file.path; + const res = await githubHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha: gitWorkspaceSvc.shaByPath[!isImg ? path : file.path], + isImg, + commitMessage, + }); + if (isImg) { + return { + sha: res.content.sha, + }; + } + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await githubHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { owner, repo, branch } = store.getters['workspace/currentWorkspace']; + const entries = await githubHelper.getCommits({ + token, + owner, + repo, + sha: branch, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } else if (commit && commit.author) { + user = commit.author; + } + const sub = `${githubHelper.subPrefix}:${user.id || user.name}`; + userSvc.addUserInfo({ id: sub, name: user.login || user.name, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date) + || 1; + return { + id: sha, + sub, + message: commit.message, + created: new Date(date).getTime(), + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await githubHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + return Provider.parseContent(data, contentId); + }, + getFilePathUrl(path) { + const workspace = store.getters['workspace/currentWorkspace']; + return `https://github.com/${workspace.owner}/${workspace.repo}/blob/${workspace.branch}${path}`; + }, +}); diff --git a/src/services/providers/gitlabProvider.js b/src/services/providers/gitlabProvider.js new file mode 100644 index 0000000..f19e723 --- /dev/null +++ b/src/services/providers/gitlabProvider.js @@ -0,0 +1,173 @@ +import store from '../../store'; +import gitlabHelper from './helpers/gitlabHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'gitlab', + name: 'GitLab', + getToken({ sub }) { + return store.getters['data/gitlabTokensBySub'][sub]; + }, + getLocationUrl({ + sub, + projectPath, + branch, + path, + }) { + const token = this.getToken({ sub }); + return `${token.serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await gitlabHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await gitlabHelper.getProjectId(token, syncLocation), + }; + if (!savedSha[updatedSyncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[updatedSyncLocation.id]; + delete savedSha[updatedSyncLocation.id]; + await gitlabHelper.uploadFile({ + ...updatedSyncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return updatedSyncLocation; + }, + async publish(token, html, metadata, publishLocation, commitMessage) { + const updatedPublishLocation = { + ...publishLocation, + projectId: await gitlabHelper.getProjectId(token, publishLocation), + }; + try { + // Get the last sha + await this.downloadContent(token, updatedPublishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[updatedPublishLocation.id]; + delete savedSha[updatedPublishLocation.id]; + await gitlabHelper.uploadFile({ + ...updatedPublishLocation, + token, + content: html, + sha, + commitMessage, + }); + return updatedPublishLocation; + }, + async openFile(token, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await gitlabHelper.getProjectId(token, syncLocation), + }; + + // Check if the file exists and open it + if (!Provider.openFileWithLocation(updatedSyncLocation)) { + // Download content from GitLab + let content; + try { + content = await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`); + return; + } + + // Create the file + let name = updatedSyncLocation.path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...updatedSyncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从GitLab导入。`); + } + }, + makeLocation(token, projectPath, branch, path) { + return { + providerId: this.id, + sub: token.sub, + projectPath, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await gitlabHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map((entry) => { + const email = entry.author_email || entry.committer_email; + const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`; + userSvc.addUserInfo({ + id: sub, + name: entry.author_name || entry.committer_name, + imageUrl: '', + }); + const date = entry.authored_date || entry.committed_date || 1; + return { + id: entry.id, + sub, + message: entry.commit && entry.commit.message, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await gitlabHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/gitlabWorkspaceProvider.js b/src/services/providers/gitlabWorkspaceProvider.js new file mode 100644 index 0000000..afb138a --- /dev/null +++ b/src/services/providers/gitlabWorkspaceProvider.js @@ -0,0 +1,317 @@ +import store from '../../store'; +import gitlabHelper from './helpers/gitlabHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import badgeSvc from '../badgeSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'gitlabWorkspace', + name: 'GitLab', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + serverUrl, + projectPath, + branch, + path, + }) { + return { + providerId: this.id, + serverUrl, + projectPath, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + serverUrl, + projectPath, + branch, + path, + }) { + return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { projectPath, branch } = store.getters['workspace/currentWorkspace']; + const { serverUrl } = this.getToken(); + return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { serverUrl, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ serverUrl, branch }); + if (!branch) { + workspaceParams.branch = 'master'; + } + + // Extract project path param + const projectPath = (utils.queryParams.projectPath || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, ''); // Remove trailing `/` + workspaceParams.projectPath = projectPath; + + // Extract path param + const path = (utils.queryParams.path || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + const sub = workspace ? workspace.sub : utils.queryParams.sub; + let token = store.getters['data/gitlabTokensBySub'][sub]; + if (!token) { + const { applicationId, applicationSecret } = await store.dispatch('modal/open', { + type: 'gitlabAccount', + forceServerUrl: serverUrl, + }); + token = await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret, sub); + } + + if (!workspace) { + const projectId = await gitlabHelper.getProjectId(token, workspaceParams); + const pathEntries = (path || '').split('/'); + const projectPathEntries = (projectPath || '').split('/'); + const name = pathEntries[pathEntries.length - 2] // path ends with `/` + || projectPathEntries[projectPathEntries.length - 1]; + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + projectId, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + badgeSvc.addBadge('addGitlabWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return gitlabHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree.map(entry => ({ + ...entry, + sha: entry.id, + }))); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + commitMessage: item.commitMessage, + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await gitlabHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadFile({ token, path }) { + const { sha, data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path, + isImg: true, + }); + return { + content: data, + sha, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + commitMessage, + }) { + const isImg = file.type === 'img'; + const path = store.getters.gitPathsByItemId[file.id]; + const absolutePath = !isImg ? `${store.getters['workspace/currentWorkspace'].path || ''}${path}` : file.path; + const sha = gitWorkspaceSvc.shaByPath[!isImg ? path : file.path]; + await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: !isImg ? Provider.serializeContent(content) : file.content, + sha, + isImg, + commitMessage, + }); + + if (isImg) { + const res2 = await this.downloadFile({ token, path: absolutePath }); + return { + sha: res2.sha, + }; + } + + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { projectId, branch } = store.getters['workspace/currentWorkspace']; + const entries = await gitlabHelper.getCommits({ + token, + projectId, + sha: branch, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + + return entries.map((entry) => { + const email = entry.author_email || entry.committer_email; + const sub = `${gitlabHelper.subPrefix}:${token.serverUrl}/${email}`; + userSvc.addUserInfo({ + id: sub, + name: entry.author_name || entry.committer_name, + imageUrl: '', // No way to get user's avatar url... + }); + const date = entry.authored_date || entry.committed_date || 1; + return { + id: entry.id, + sub, + message: entry.commit && entry.commit.message, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js new file mode 100644 index 0000000..196f635 --- /dev/null +++ b/src/services/providers/googleDriveAppDataProvider.js @@ -0,0 +1,187 @@ +import store from '../../store'; +import googleHelper from './helpers/googleHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; + +let syncStartPageToken; + +export default new Provider({ + id: 'googleDriveAppData', + name: 'Google Drive app data', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams() { + // No param as it's the main workspace + return {}; + }, + getWorkspaceLocationUrl() { + // No direct link to app data + return null; + }, + getSyncDataUrl() { + // No direct link to app data + return null; + }, + getSyncDataDescription({ id }) { + return id; + }, + async initWorkspace() { + // Nothing much to do since the main workspace isn't necessarily synchronized + // Return the main workspace + return store.getters['workspace/workspacesById'].main; + }, + async getChanges() { + const syncToken = store.getters['workspace/syncToken']; + const startPageToken = store.getters['data/localSettings'].syncStartPageToken; + const result = await googleHelper.getChanges(syncToken, startPageToken, true); + const changes = result.changes.filter((change) => { + if (change.file) { + // Parse item from file name + try { + change.item = JSON.parse(change.file.name); + } catch (e) { + return false; + } + // Build sync data + change.syncData = { + id: change.fileId, + itemId: change.item.id, + type: change.item.type, + hash: change.item.hash, + }; + } + change.syncDataId = change.fileId; + return true; + }); + syncStartPageToken = result.startPageToken; + return changes; + }, + onChangesApplied() { + store.dispatch('data/patchLocalSettings', { + syncStartPageToken, + }); + }, + async saveWorkspaceItem({ item, syncData, ifNotTooLate }) { + const syncToken = store.getters['workspace/syncToken']; + const file = await googleHelper.uploadAppDataFile({ + token: syncToken, + name: JSON.stringify(item), + fileId: syncData && syncData.id, + ifNotTooLate, + }); + + // Build sync data to save + return { + syncData: { + id: file.id, + itemId: item.id, + type: item.type, + hash: item.hash, + }, + }; + }, + removeWorkspaceItem({ syncData, ifNotTooLate }) { + const syncToken = store.getters['workspace/syncToken']; + return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate); + }, + async downloadWorkspaceContent({ token, contentSyncData }) { + const data = await googleHelper.downloadAppDataFile(token, contentSyncData.id); + const content = utils.addItemHash(JSON.parse(data)); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + }, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const data = await googleHelper.downloadAppDataFile(token, syncData.id); + const item = utils.addItemHash(JSON.parse(data)); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + contentSyncData, + ifNotTooLate, + }) { + const gdriveFile = await googleHelper.uploadAppDataFile({ + token, + name: JSON.stringify({ + id: content.id, + type: content.type, + hash: content.hash, + }), + media: JSON.stringify(content), + fileId: contentSyncData && contentSyncData.id, + ifNotTooLate, + }); + + // Return new sync data + return { + contentSyncData: { + id: gdriveFile.id, + itemId: content.id, + type: content.type, + hash: content.hash, + }, + }; + }, + async uploadWorkspaceData({ + token, + item, + syncData, + ifNotTooLate, + }) { + const file = await googleHelper.uploadAppDataFile({ + token, + name: JSON.stringify({ + id: item.id, + type: item.type, + hash: item.hash, + }), + media: JSON.stringify(item), + fileId: syncData && syncData.id, + ifNotTooLate, + }); + + // Return new sync data + return { + syncData: { + id: file.id, + itemId: item.id, + type: item.type, + hash: item.hash, + }, + }; + }, + async listFileRevisions({ token, contentSyncDataId }) { + const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncDataId); + return revisions.map(revision => ({ + id: revision.id, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, + created: new Date(revision.modifiedTime).getTime(), + })); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ token, contentSyncDataId, revisionId }) { + const content = await googleHelper + .downloadAppDataFileRevision(token, contentSyncDataId, revisionId); + return JSON.parse(content); + }, +}); diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js new file mode 100644 index 0000000..0dcb774 --- /dev/null +++ b/src/services/providers/googleDriveProvider.js @@ -0,0 +1,216 @@ +import store from '../../store'; +import googleHelper from './helpers/googleHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; + +export default new Provider({ + id: 'googleDrive', + name: 'Google Drive', + getToken({ sub }) { + const token = store.getters['data/googleTokensBySub'][sub]; + return token && token.isDrive ? token : null; + }, + getLocationUrl({ driveFileId }) { + return `https://docs.google.com/file/d/${driveFileId}/edit`; + }, + getLocationDescription({ driveFileId }) { + return driveFileId; + }, + async initAction() { + const state = googleHelper.driveState || {}; + if (state.userId) { + // Try to find the token corresponding to the user ID + let token = store.getters['data/googleTokensBySub'][state.userId]; + // If not found or not enough permission, popup an OAuth2 window + if (!token || !token.isDrive) { + await store.dispatch('modal/open', { type: 'googleDriveAccount' }); + token = await googleHelper.addDriveAccount( + !store.getters['data/localSettings'].googleDriveRestrictedAccess, + state.userId, + ); + } + + const openWorkspaceIfExists = (file) => { + const folderId = file + && file.appProperties + && file.appProperties.folderId; + if (folderId) { + // See if we have the corresponding workspace + const workspaceParams = { + providerId: 'googleDriveWorkspace', + folderId, + }; + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + // If we have the workspace, open it by changing the current URL + if (workspace) { + utils.setQueryParams(workspaceParams); + } + } + }; + + switch (state.action) { + case 'create': + default: + // See if folder is part of a workspace we can open + try { + const folder = await googleHelper.getFile(token, state.folderId); + folder.appProperties = folder.appProperties || {}; + googleHelper.driveActionFolder = folder; + openWorkspaceIfExists(folder); + } catch (err) { + if (!err || err.status !== 404) { + throw err; + } + // We received an HTTP 404 meaning we have no permission to read the folder + googleHelper.driveActionFolder = { id: state.folderId }; + } + break; + + case 'open': { + await utils.awaitSequence(state.ids || [], async (id) => { + const file = await googleHelper.getFile(token, id); + file.appProperties = file.appProperties || {}; + googleHelper.driveActionFiles.push(file); + }); + + // Check if first file is part of a workspace + openWorkspaceIfExists(googleHelper.driveActionFiles[0]); + } + } + } + }, + async performAction() { + const state = googleHelper.driveState || {}; + const token = store.getters['data/googleTokensBySub'][state.userId]; + switch (token && state.action) { + case 'create': { + const file = await workspaceSvc.createFile({}, true); + store.commit('file/setCurrentId', file.id); + // Return a new syncLocation + return this.makeLocation(token, null, googleHelper.driveActionFolder.id); + } + case 'open': + store.dispatch( + 'queue/enqueue', + () => this.openFiles(token, googleHelper.driveActionFiles), + ); + return null; + default: + return null; + } + }, + async downloadContent(token, syncLocation) { + const content = await googleHelper.downloadFile(token, syncLocation.driveFileId); + return Provider.parseContent(content, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation, ifNotTooLate) { + const file = store.state.file.itemsById[syncLocation.fileId]; + const name = utils.sanitizeName(file && file.name); + const parents = []; + if (syncLocation.driveParentId) { + parents.push(syncLocation.driveParentId); + } + const driveFile = await googleHelper.uploadFile({ + token, + name, + parents, + media: Provider.serializeContent(content), + fileId: syncLocation.driveFileId, + ifNotTooLate, + }); + return { + ...syncLocation, + driveFileId: driveFile.id, + }; + }, + async publish(token, html, metadata, publishLocation) { + const driveFile = await googleHelper.uploadFile({ + token, + name: metadata.title, + parents: [], + media: html, + mediaType: publishLocation.templateId ? 'text/html' : undefined, + fileId: publishLocation.driveFileId, + }); + return { + ...publishLocation, + driveFileId: driveFile.id, + }; + }, + async openFiles(token, driveFiles) { + return utils.awaitSequence(driveFiles, async (driveFile) => { + // Check if the file exists and open it + if (!Provider.openFileWithLocation({ + providerId: this.id, + driveFileId: driveFile.id, + })) { + // Download content from Google Drive + const syncLocation = { + driveFileId: driveFile.id, + providerId: this.id, + sub: token.sub, + }; + let content; + try { + content = await this.downloadContent(token, syncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${driveFile.id}.`); + return; + } + + // Create the file + const item = await workspaceSvc.createFile({ + name: driveFile.name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...syncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name}已从Google Drive导入。`); + } + }); + }, + makeLocation(token, fileId, folderId) { + const location = { + providerId: this.id, + sub: token.sub, + }; + if (fileId) { + location.driveFileId = fileId; + } + if (folderId) { + location.driveParentId = folderId; + } + return location; + }, + async listFileRevisions({ token, syncLocation }) { + const revisions = await googleHelper.getFileRevisions(token, syncLocation.driveFileId); + return revisions.map(revision => ({ + id: revision.id, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, + created: new Date(revision.modifiedTime).getTime(), + })); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const content = await googleHelper + .downloadFileRevision(token, syncLocation.driveFileId, revisionId); + return Provider.parseContent(content, contentId); + }, +}); diff --git a/src/services/providers/googleDriveWorkspaceProvider.js b/src/services/providers/googleDriveWorkspaceProvider.js new file mode 100644 index 0000000..0f9429b --- /dev/null +++ b/src/services/providers/googleDriveWorkspaceProvider.js @@ -0,0 +1,534 @@ +import store from '../../store'; +import googleHelper from './helpers/googleHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import badgeSvc from '../badgeSvc'; + +let fileIdToOpen; +let syncStartPageToken; + +export default new Provider({ + id: 'googleDriveWorkspace', + name: 'Google Drive', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ folderId }) { + return { + providerId: this.id, + folderId, + }; + }, + getWorkspaceLocationUrl({ folderId }) { + return `https://docs.google.com/folder/d/${folderId}`; + }, + getSyncDataUrl({ id }) { + return `https://docs.google.com/file/d/${id}/edit`; + }, + getSyncDataDescription({ id }) { + return id; + }, + async initWorkspace() { + const makeWorkspaceId = folderId => folderId + && utils.makeWorkspaceId(this.getWorkspaceParams({ folderId })); + + const getWorkspace = folderId => + store.getters['workspace/workspacesById'][makeWorkspaceId(folderId)]; + + const initFolder = async (token, folder) => { + const appProperties = { + folderId: folder.id, + dataFolderId: folder.appProperties.dataFolderId, + trashFolderId: folder.appProperties.trashFolderId, + }; + + // Make sure data folder exists + if (!appProperties.dataFolderId) { + const dataFolder = await googleHelper.uploadFile({ + token, + name: '.stackedit-data', + parents: [folder.id], + appProperties: { folderId: folder.id }, + mediaType: googleHelper.folderMimeType, + }); + appProperties.dataFolderId = dataFolder.id; + } + + // Make sure trash folder exists + if (!appProperties.trashFolderId) { + const trashFolder = await googleHelper.uploadFile({ + token, + name: '.stackedit-trash', + parents: [folder.id], + appProperties: { folderId: folder.id }, + mediaType: googleHelper.folderMimeType, + }); + appProperties.trashFolderId = trashFolder.id; + } + + // Update workspace if some properties are missing + if (appProperties.folderId !== folder.appProperties.folderId + || appProperties.dataFolderId !== folder.appProperties.dataFolderId + || appProperties.trashFolderId !== folder.appProperties.trashFolderId + ) { + await googleHelper.uploadFile({ + token, + appProperties, + mediaType: googleHelper.folderMimeType, + fileId: folder.id, + }); + } + + // Update workspace in the store + const workspaceId = makeWorkspaceId(folder.id); + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + id: workspaceId, + sub: token.sub, + name: folder.name, + providerId: this.id, + folderId: folder.id, + teamDriveId: folder.teamDriveId, + dataFolderId: appProperties.dataFolderId, + trashFolderId: appProperties.trashFolderId, + }, + }); + }; + + // Token sub is in the workspace or in the url if workspace is about to be created + const { sub } = getWorkspace(utils.queryParams.folderId) || utils.queryParams; + // See if we already have a token + let token = store.getters['data/googleTokensBySub'][sub]; + // If no token has been found, popup an authorize window and get one + if (!token || !token.isDrive || !token.driveFullAccess) { + await store.dispatch('modal/open', 'workspaceGoogleRedirection'); + token = await googleHelper.addDriveAccount(true, utils.queryParams.sub); + } + + let { folderId } = utils.queryParams; + // If no folderId is provided, create one + if (!folderId) { + const folder = await googleHelper.uploadFile({ + token, + name: 'StackEdit workspace', + parents: [], + mediaType: googleHelper.folderMimeType, + }); + await initFolder(token, { + ...folder, + appProperties: {}, + }); + folderId = folder.id; + } + + // Init workspace + if (!getWorkspace(folderId)) { + let folder; + try { + folder = await googleHelper.getFile(token, folderId); + } catch (err) { + throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`); + } + folder.appProperties = folder.appProperties || {}; + const folderIdProperty = folder.appProperties.folderId; + if (folderIdProperty && folderIdProperty !== folderId) { + throw new Error(`Folder ${folderId} is part of another workspace.`); + } + await initFolder(token, folder); + } + + badgeSvc.addBadge('addGoogleDriveWorkspace'); + return getWorkspace(folderId); + }, + async performAction() { + const state = googleHelper.driveState || {}; + const token = this.getToken(); + switch (token && state.action) { + case 'create': { + const driveFolder = googleHelper.driveActionFolder; + let syncData = store.getters['data/syncDataById'][driveFolder.id]; + if (!syncData && driveFolder.appProperties.id) { + // Create folder if not already synced + store.commit('folder/setItem', { + id: driveFolder.appProperties.id, + name: driveFolder.name, + }); + const item = store.state.folder.itemsById[driveFolder.appProperties.id]; + syncData = { + id: driveFolder.id, + itemId: item.id, + type: item.type, + hash: item.hash, + }; + store.dispatch('data/patchSyncDataById', { + [syncData.id]: syncData, + }); + } + const file = await workspaceSvc.createFile({ + parentId: syncData && syncData.itemId, + }, true); + store.commit('file/setCurrentId', file.id); + // File will be created on next workspace sync + break; + } + case 'open': { + // open first file only + const firstFile = googleHelper.driveActionFiles[0]; + const syncData = store.getters['data/syncDataById'][firstFile.id]; + if (!syncData) { + fileIdToOpen = firstFile.id; + } else { + store.commit('file/setCurrentId', syncData.itemId); + } + break; + } + default: + } + }, + async getChanges() { + const workspace = store.getters['workspace/currentWorkspace']; + const syncToken = store.getters['workspace/syncToken']; + const lastStartPageToken = store.getters['data/localSettings'].syncStartPageToken; + const { changes, startPageToken } = await googleHelper + .getChanges(syncToken, lastStartPageToken, false, workspace.teamDriveId); + + syncStartPageToken = startPageToken; + return changes; + }, + prepareChanges(changes) { + // Collect possible parent IDs + const parentIds = {}; + Object.entries(store.getters['data/syncDataByItemId']).forEach(([id, syncData]) => { + parentIds[syncData.id] = id; + }); + changes.forEach((change) => { + const { id } = (change.file || {}).appProperties || {}; + if (id) { + parentIds[change.fileId] = id; + } + }); + + // Collect changes + const workspace = store.getters['workspace/currentWorkspace']; + const result = []; + changes.forEach((change) => { + // Ignore changes on StackEdit own folders + if (change.fileId === workspace.folderId + || change.fileId === workspace.dataFolderId + || change.fileId === workspace.trashFolderId + ) { + return; + } + + let contentChange; + if (change.file) { + // Ignore changes in files that are not in the workspace + const { appProperties } = change.file; + if (!appProperties || appProperties.folderId !== workspace.folderId + ) { + return; + } + + // If change is on a data item + if (change.file.parents[0] === workspace.dataFolderId) { + // Data item has a JSON filename + try { + change.item = JSON.parse(change.file.name); + } catch (e) { + return; + } + } else { + // Change on a file or folder + const type = change.file.mimeType === googleHelper.folderMimeType + ? 'folder' + : 'file'; + const item = { + id: appProperties.id, + type, + name: change.file.name, + parentId: null, + }; + + // Fill parentId + if (change.file.parents.some(parentId => parentId === workspace.trashFolderId)) { + item.parentId = 'trash'; + } else { + change.file.parents.some((parentId) => { + if (!parentIds[parentId]) { + return false; + } + item.parentId = parentIds[parentId]; + return true; + }); + } + change.item = utils.addItemHash(item); + + if (type === 'file') { + // create a fake change as a file content change + const id = `${appProperties.id}/content`; + const syncDataId = `${change.fileId}/content`; + contentChange = { + item: { + id, + type: 'content', + // Need a truthy value to force saving sync data + hash: 1, + }, + syncData: { + id: syncDataId, + itemId: id, + type: 'content', + // Need a truthy value to force downloading the content + hash: 1, + }, + syncDataId, + }; + } + } + + // Build sync data + change.syncData = { + id: change.fileId, + parentIds: change.file.parents, + itemId: change.item.id, + type: change.item.type, + hash: change.item.hash, + }; + } else { + // Item was removed + const syncData = store.getters['data/syncDataById'][change.fileId]; + if (syncData && syncData.type === 'file') { + // create a fake change as a file content change + contentChange = { + syncDataId: `${change.fileId}/content`, + }; + } + } + + // Push change + change.syncDataId = change.fileId; + result.push(change); + if (contentChange) { + result.push(contentChange); + } + }); + + return result; + }, + onChangesApplied() { + store.dispatch('data/patchLocalSettings', { + syncStartPageToken, + }); + }, + async saveWorkspaceItem({ item, syncData, ifNotTooLate }) { + const workspace = store.getters['workspace/currentWorkspace']; + const syncToken = store.getters['workspace/syncToken']; + let file; + if (item.type !== 'file' && item.type !== 'folder') { + // For sync/publish locations, store item as filename + file = await googleHelper.uploadFile({ + token: syncToken, + name: JSON.stringify(item), + parents: [workspace.dataFolderId], + appProperties: { + folderId: workspace.folderId, + }, + fileId: syncData && syncData.id, + oldParents: syncData && syncData.parentIds, + ifNotTooLate, + }); + } else { + // For type `file` or `folder` + const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId]; + let parentId; + if (item.parentId === 'trash') { + parentId = workspace.trashFolderId; + } else if (parentSyncData) { + parentId = parentSyncData.id; + } else { + parentId = workspace.folderId; + } + + file = await googleHelper.uploadFile({ + token: syncToken, + name: item.name, + parents: [parentId], + appProperties: { + id: item.id, + folderId: workspace.folderId, + }, + mediaType: item.type === 'folder' ? googleHelper.folderMimeType : undefined, + fileId: syncData && syncData.id, + oldParents: syncData && syncData.parentIds, + ifNotTooLate, + }); + } + + // Build sync data to save + return { + syncData: { + id: file.id, + parentIds: file.parents, + itemId: item.id, + type: item.type, + hash: item.hash, + }, + }; + }, + async removeWorkspaceItem({ syncData, ifNotTooLate }) { + // Ignore content deletion + if (syncData.type !== 'content') { + const syncToken = store.getters['workspace/syncToken']; + await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate); + } + }, + async downloadWorkspaceContent({ token, contentSyncData, fileSyncData }) { + const data = await googleHelper.downloadFile(token, fileSyncData.id); + const content = Provider.parseContent(data, contentSyncData.itemId); + + // Open the file requested by action if it wasn't synced yet + if (fileIdToOpen && fileIdToOpen === fileSyncData.id) { + fileIdToOpen = null; + // Open the file once downloaded content has been stored + setTimeout(() => { + store.commit('file/setCurrentId', fileSyncData.itemId); + }, 10); + } + + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + }, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const content = await googleHelper.downloadFile(token, syncData.id); + const item = JSON.parse(content); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + }, + }; + }, + async uploadWorkspaceContent({ + token, + content, + file, + fileSyncData, + ifNotTooLate, + }) { + let gdriveFile; + let newFileSyncData; + + if (fileSyncData) { + // Only update file media + gdriveFile = await googleHelper.uploadFile({ + token, + media: Provider.serializeContent(content), + fileId: fileSyncData.id, + ifNotTooLate, + }); + } else { + // Create file with media + const workspace = store.getters['workspace/currentWorkspace']; + const parentSyncData = store.getters['data/syncDataByItemId'][file.parentId]; + gdriveFile = await googleHelper.uploadFile({ + token, + name: file.name, + parents: [parentSyncData ? parentSyncData.id : workspace.folderId], + appProperties: { + id: file.id, + folderId: workspace.folderId, + }, + media: Provider.serializeContent(content), + ifNotTooLate, + }); + + // Create file sync data + newFileSyncData = { + id: gdriveFile.id, + parentIds: gdriveFile.parents, + itemId: file.id, + type: file.type, + hash: file.hash, + }; + } + + // Return new sync data + return { + contentSyncData: { + id: `${gdriveFile.id}/content`, + itemId: content.id, + type: content.type, + hash: content.hash, + }, + fileSyncData: newFileSyncData, + }; + }, + async uploadWorkspaceData({ + token, + item, + syncData, + ifNotTooLate, + }) { + const workspace = store.getters['workspace/currentWorkspace']; + const file = await googleHelper.uploadFile({ + token, + name: JSON.stringify({ + id: item.id, + type: item.type, + hash: item.hash, + }), + parents: [workspace.dataFolderId], + appProperties: { + folderId: workspace.folderId, + }, + media: JSON.stringify(item), + mediaType: 'application/json', + fileId: syncData && syncData.id, + oldParents: syncData && syncData.parentIds, + ifNotTooLate, + }); + + // Return new sync data + return { + syncData: { + id: file.id, + parentIds: file.parents, + itemId: item.id, + type: item.type, + hash: item.hash, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const revisions = await googleHelper.getFileRevisions(token, fileSyncDataId); + return revisions.map(revision => ({ + id: revision.id, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, + created: new Date(revision.modifiedTime).getTime(), + })); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const content = await googleHelper.downloadFileRevision(token, fileSyncDataId, revisionId); + return Provider.parseContent(content, contentId); + }, +}); diff --git a/src/services/providers/helpers/couchdbHelper.js b/src/services/providers/helpers/couchdbHelper.js new file mode 100644 index 0000000..84eca0e --- /dev/null +++ b/src/services/providers/helpers/couchdbHelper.js @@ -0,0 +1,194 @@ +import networkSvc from '../../networkSvc'; +import utils from '../../utils'; +import store from '../../../store'; +import userSvc from '../../userSvc'; + +const request = async (token, options = {}) => { + const baseUrl = `${token.dbUrl}/`; + const getLastToken = () => store.getters['data/couchdbTokensBySub'][token.sub]; + + const assertUnauthorized = (err) => { + if (err.status !== 401) { + throw err; + } + }; + + const onUnauthorized = async () => { + try { + const { name, password } = getLastToken(); + await networkSvc.request({ + method: 'POST', + url: utils.resolveUrl(baseUrl, '../_session'), + withCredentials: true, + body: { + name, + password, + }, + }); + } catch (err) { + assertUnauthorized(err); + await store.dispatch('modal/open', { + type: 'couchdbCredentials', + token: getLastToken(), + }); + await onUnauthorized(); + } + }; + + const config = { + ...options, + headers: { + Accept: 'application/json', + ...options.headers || {}, + }, + url: utils.resolveUrl(baseUrl, options.path || '.'), + withCredentials: true, + }; + + try { + let res; + try { + res = await networkSvc.request(config); + } catch (err) { + assertUnauthorized(err); + await onUnauthorized(); + res = await networkSvc.request(config); + } + return res.body; + } catch (err) { + if (err.status === 409) { + throw new Error('TOO_LATE'); + } + throw err; + } +}; + +export default { + + /** + * http://docs.couchdb.org/en/2.1.1/api/database/common.html#db + */ + getDb(token) { + return request(token); + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/database/changes.html#db-changes + */ + async getChanges(token, lastSeq) { + const result = { + changes: [], + lastSeq, + }; + + const getPage = async () => { + const body = await request(token, { + method: 'GET', + path: '_changes', + params: { + since: result.lastSeq || 0, + include_docs: true, + limit: 1000, + }, + }); + result.changes = [...result.changes, ...body.results]; + result.lastSeq = body.last_seq; + if (body.pending) { + return getPage(); + } + return result; + }; + + return getPage(); + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/database/common.html#post--db + * http://docs.couchdb.org/en/2.1.1/api/document/common.html#put--db-docid + */ + async uploadDocument({ + token, + item, + data = null, + dataType = null, + documentId = null, + rev = null, + }) { + const options = { + method: 'POST', + body: { item, time: Date.now() }, + }; + const userId = userSvc.getCurrentUserId(); + if (userId) { + options.body.sub = userId; + } + if (documentId) { + options.method = 'PUT'; + options.path = documentId; + options.body._rev = rev; // eslint-disable-line no-underscore-dangle + } + if (data) { + options.body._attachments = { // eslint-disable-line no-underscore-dangle + data: { + content_type: dataType, + data: utils.encodeBase64(data), + }, + }; + } + return request(token, options); + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/document/common.html#delete--db-docid + */ + async removeDocument(token, documentId, rev) { + if (!documentId) { + // Prevent from deleting the whole database + throw new Error('Missing document ID'); + } + + return request(token, { + method: 'DELETE', + path: documentId, + params: { rev }, + }); + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid + */ + async retrieveDocument(token, documentId, rev) { + return request(token, { + path: documentId, + params: { rev }, + }); + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid + */ + async retrieveDocumentWithAttachments(token, documentId, rev) { + const body = await request(token, { + path: documentId, + params: { attachments: true, rev }, + }); + body.attachments = {}; + // eslint-disable-next-line no-underscore-dangle + Object.entries(body._attachments).forEach(([name, attachment]) => { + body.attachments[name] = utils.decodeBase64(attachment.data); + }); + return body; + }, + + /** + * http://docs.couchdb.org/en/2.1.1/api/document/common.html#get--db-docid + */ + async retrieveDocumentWithRevisions(token, documentId) { + return request(token, { + path: documentId, + params: { + revs_info: true, + }, + }); + }, +}; diff --git a/src/services/providers/helpers/customHelper.js b/src/services/providers/helpers/customHelper.js new file mode 100644 index 0000000..272b4fc --- /dev/null +++ b/src/services/providers/helpers/customHelper.js @@ -0,0 +1,69 @@ +import md5 from 'js-md5'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; +import utils from '../../utils'; + +/** + * 自定义账号前缀 + */ +const subPrefix = 'cs'; +export default { + subPrefix, + async addAccount({ + name, + uploadUrl, + fileParamName, + customHeaders, + customParams, + resultUrlParam, + }) { + userSvc.addUserInfo({ + id: `${subPrefix}:${utils.encodeBase64(name)}`, + name, + imageUrl: '', + }); + // Build token object including sub + const token = { + uploadUrl, + fileParamName, + customHeaders, + customParams, + resultUrlParam, + name, + sub: utils.encodeBase64(name), + }; + // Add token to smms tokens + store.dispatch('data/addCustomToken', token); + badgeSvc.addBadge('addCustomAccount'); + return token; + }, + async uploadFile({ + token, + file, + }) { + const newFileName = `${md5(await utils.encodeFiletoBase64(file))}.${file.type.split('/')[1]}`; + const newfile = new File([file], newFileName, { type: file.type }); + const headers = token.customHeaders || {}; + const formData = token.customParams || {}; + formData[token.fileParamName] = newfile; + const { body } = await networkSvc.request({ + method: 'POST', + url: token.uploadUrl, + headers, + formData, + }); + const paramArray = token.resultUrlParam.split('.'); + let result = body; + paramArray.forEach((paramName) => { + result = result[paramName]; + if (!result) { + store.dispatch('notification/error', `自定义图床上传图片失败,响应Body为:${JSON.stringify(body)}`); + throw new Error(`自定义图床上传图片失败,响应Body为:${JSON.stringify(body)}`); + } + }); + return result; + }, + +}; diff --git a/src/services/providers/helpers/dropboxHelper.js b/src/services/providers/helpers/dropboxHelper.js new file mode 100644 index 0000000..f9082c2 --- /dev/null +++ b/src/services/providers/helpers/dropboxHelper.js @@ -0,0 +1,190 @@ +import networkSvc from '../../networkSvc'; +import userSvc from '../../userSvc'; +import store from '../../../store'; +import badgeSvc from '../../badgeSvc'; + +const getAppKey = (fullAccess) => { + if (fullAccess) { + return store.getters['data/serverConf'].dropboxAppKeyFull; + } + return store.getters['data/serverConf'].dropboxAppKey; +}; + +const httpHeaderSafeJson = args => args && JSON.stringify(args) + .replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`); + +const request = ({ accessToken }, options, args) => networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + 'Content-Type': options.body && (typeof options.body === 'string' + ? 'application/octet-stream' : 'application/json; charset=utf-8'), + 'Dropbox-API-Arg': httpHeaderSafeJson(args), + Authorization: `Bearer ${accessToken}`, + }, +}); + +/** + * https://www.dropbox.com/developers/documentation/http/documentation#users-get_account + */ +const subPrefix = 'db'; +userSvc.setInfoResolver('dropbox', subPrefix, async (sub) => { + const dropboxToken = Object.values(store.getters['data/dropboxTokensBySub'])[0]; + try { + const { body } = await request(dropboxToken, { + method: 'POST', + url: 'https://api.dropboxapi.com/2/users/get_account', + body: { + account_id: sub, + }, + }); + + return { + id: `${subPrefix}:${body.account_id}`, + name: body.name.display_name, + imageUrl: body.profile_photo_url || '', + }; + } catch (err) { + if (!dropboxToken || err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize + * https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account + */ + async startOauth2(fullAccess, sub = null, silent = false) { + // Get an OAuth2 code + const { accessToken } = await networkSvc.startOauth2( + 'https://www.dropbox.com/oauth2/authorize', + { + client_id: getAppKey(fullAccess), + response_type: 'token', + }, + silent, + ); + + // Call the user info endpoint + const { body } = await request({ accessToken }, { + method: 'POST', + url: 'https://api.dropboxapi.com/2/users/get_current_account', + }); + userSvc.addUserInfo({ + id: `${subPrefix}:${body.account_id}`, + name: body.name.display_name, + imageUrl: body.profile_photo_url || '', + }); + + // Check the returned sub consistency + if (sub && `${body.account_id}` !== sub) { + throw new Error('Dropbox account ID not expected.'); + } + + // Build token object including scopes and sub + const token = { + accessToken, + name: body.name.display_name, + sub: `${body.account_id}`, + fullAccess, + }; + + // Add token to dropbox tokens + store.dispatch('data/addDropboxToken', token); + return token; + }, + async addAccount(fullAccess = false) { + const token = await this.startOauth2(fullAccess); + badgeSvc.addBadge('addDropboxAccount'); + return token; + }, + + /** + * https://www.dropbox.com/developers/documentation/http/documentation#files-upload + */ + async uploadFile({ + token, + path, + content, + fileId, + }) { + return (await request(token, { + method: 'POST', + url: 'https://content.dropboxapi.com/2/files/upload', + body: content, + }, { + path: fileId || path, + mode: 'overwrite', + })).body; + }, + + /** + * https://www.dropbox.com/developers/documentation/http/documentation#files-download + */ + async downloadFile({ + token, + path, + fileId, + }) { + const res = await request(token, { + method: 'POST', + url: 'https://content.dropboxapi.com/2/files/download', + raw: true, + }, { + path: fileId || path, + }); + return { + id: JSON.parse(res.headers['dropbox-api-result']).id, + content: res.body, + }; + }, + + /** + * https://www.dropbox.com/developers/documentation/http/documentation#list-revisions + */ + async listRevisions({ + token, + path, + fileId, + }) { + const res = await request(token, { + method: 'POST', + url: 'https://api.dropboxapi.com/2/files/list_revisions', + body: fileId ? { + path: fileId, + mode: 'id', + limit: 100, + } : { + path, + limit: 100, + }, + }); + return res.body.entries; + }, + + /** + * https://www.dropbox.com/developers/chooser + */ + async openChooser(token) { + if (!window.Dropbox) { + await networkSvc.loadScript('https://www.dropbox.com/static/api/2/dropins.js'); + } + return new Promise((resolve) => { + window.Dropbox.appKey = getAppKey(token.fullAccess); + window.Dropbox.choose({ + multiselect: true, + linkType: 'direct', + success: files => resolve(files.map((file) => { + const path = file.link.replace(/.*\/view\/[^/]*/, ''); + return decodeURI(path); + })), + cancel: () => resolve([]), + }); + }); + }, +}; diff --git a/src/services/providers/helpers/giteaHelper.js b/src/services/providers/helpers/giteaHelper.js new file mode 100644 index 0000000..c6188b2 --- /dev/null +++ b/src/services/providers/helpers/giteaHelper.js @@ -0,0 +1,385 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; + +const tokenExpirationMargin = 5 * 60 * 1000; + +const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ + ...options, + url: `${serverUrl}/api/v1/${options.url}`, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${accessToken}`, + }, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * https://try.gitea.io/api/swagger#/user/userGet + */ +const subPrefix = 'gt'; +userSvc.setInfoResolver('gitea', subPrefix, async (sub) => { + try { + const [, serverUrl, username] = sub.match(/^(.+)\/([^/]+)$/); + const user = (await networkSvc.request({ + url: `${serverUrl}/api/v1/users/${username}`, + })).body; + const uniqueSub = `${serverUrl}/${user.username}`; + + return { + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://docs.gitea.io/en-us/oauth2-provider/ + */ + async startOauth2( + serverUrl, applicationId, applicationSecret, + sub = null, silent = false, refreshToken, + ) { + let apiUrl = serverUrl; + let clientId = applicationId; + let useServerConf = false; + // 获取gitea配置的参数 + await networkSvc.getServerConf(); + const confClientId = store.getters['data/serverConf'].giteaClientId; + const confServerUrl = store.getters['data/serverConf'].giteaUrl; + // 存在gitea配置则使用后端配置 + if (confClientId && confServerUrl) { + apiUrl = confServerUrl; + clientId = confClientId; + useServerConf = true; + } + let tokenBody; + if (!silent) { + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + `${apiUrl}/login/oauth/authorize`, + { + client_id: clientId, + response_type: 'code', + redirect_uri: constants.oauth2RedirectUri, + }, + silent, + ); + if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/giteaToken', + params: { + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange code with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/login/oauth/access_token`, + body: { + client_id: clientId, + client_secret: applicationSecret, + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } + } else if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/giteaToken', + params: { + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange refreshToken with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/login/oauth/access_token`, + body: { + client_id: clientId, + client_secret: applicationSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } + + const accessToken = tokenBody.access_token; + + // Call the user info endpoint + const user = await request({ accessToken, serverUrl: apiUrl }, { + url: 'user', + }); + const uniqueSub = `${apiUrl}/${user.username}`; + userSvc.addUserInfo({ + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }); + + // Check the returned sub consistency + if (sub && uniqueSub !== sub) { + throw new Error('Gitea account ID not expected.'); + } + + const oldToken = store.getters['data/giteaTokensBySub'][uniqueSub]; + // Build token object including scopes and sub + const token = { + accessToken, + name: user.username, + applicationId: clientId, + applicationSecret, + imgStorages: oldToken && oldToken.imgStorages, + refreshToken: tokenBody.refresh_token, + expiresOn: Date.now() + (tokenBody.expires_in * 1000), + serverUrl: apiUrl, + sub: uniqueSub, + }; + + // Add token to gitea tokens + store.dispatch('data/addGiteaToken', token); + return token; + }, + // 刷新token + async refreshToken(token) { + const { + serverUrl, + applicationId, + applicationSecret, + sub, + } = token; + const lastToken = store.getters['data/giteaTokensBySub'][sub]; + // 兼容旧的没有过期时间 + if (!lastToken.expiresOn) { + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitea', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + // lastToken is not expired + if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { + return lastToken; + } + + // existing token is about to expire. + // Try to get a new token in background + try { + return await this.startOauth2( + serverUrl, applicationId, applicationSecret, + sub, true, lastToken.refreshToken, + ); + } catch (err) { + // If it fails try to popup a window + if (store.state.offline) { + throw err; + } + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitea', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + }, + async addAccount({ + serverUrl, + applicationId, + applicationSecret, + }, sub = null) { + const token = await this.startOauth2( + serverUrl, + applicationId, + applicationSecret, + sub, + ); + badgeSvc.addBadge('addGiteaAccount'); + return token; + }, + async updateToken(token, imgStorageInfo) { + const imgStorages = token.imgStorages || []; + // 存储仓库唯一标识 + const sid = utils.hash(`${imgStorageInfo.repoUri}${imgStorageInfo.path}${imgStorageInfo.branch}`); + // 查询是否存在 存在则更新 + const filterStorages = imgStorages.filter(it => it.sid === sid); + if (filterStorages && filterStorages.length > 0) { + filterStorages.repoUri = imgStorageInfo.repoUri; + filterStorages.path = imgStorageInfo.path; + filterStorages.branch = imgStorageInfo.branch; + } else { + imgStorages.push({ + sid, + repoUri: imgStorageInfo.repoUri, + path: imgStorageInfo.path, + branch: imgStorageInfo.branch, + }); + token.imgStorages = imgStorages; + } + store.dispatch('data/addGiteaToken', token); + }, + async removeTokenImgStorage(token, sid) { + if (!token.imgStorages || token.imgStorages.length === 0) { + return; + } + token.imgStorages = token.imgStorages.filter(it => it.sid !== sid); + store.dispatch('data/addGiteaToken', token); + }, + async getProjectId(token, { projectPath, projectId }) { + if (projectId) { + return projectId; + } + const repoInfo = await this.getRepoInfo(token, projectPath); + return repoInfo.full_name; + }, + /** + * https://try.gitea.io/api/swagger#/repository/repoGet + */ + async getRepoInfo(token, projectPath) { + const [, repoFullName] = projectPath.match(/([^/]+\/[^/]+)$/); + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { url: `repos/${repoFullName}` }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/GetTree + */ + async getTree({ + token, + projectId, + branch, + }) { + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { + url: `repos/${projectId}/git/trees/${branch}`, + params: { + recursive: true, + per_page: 9999, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoGetAllCommits + */ + async getCommits({ + token, + projectId, + branch, + path, + }) { + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { + url: `repos/${projectId}/commits`, + params: { + sha: branch, + path, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoCreateFile + * https://try.gitea.io/api/swagger#/repository/repoUpdateFile + */ + async uploadFile({ + token, + projectId, + branch, + path, + content, + sha, + isImg, + commitMessage, + }) { + // 非法的文件名 不让提交 + if (!path || path.endsWith('undefined')) { + return new Promise((resolve) => { + resolve({ res: { content: { sha: null } } }); + }); + } + let uploadContent = content; + if (isImg && typeof content !== 'string') { + uploadContent = await utils.encodeFiletoBase64(content); + } + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { + method: sha ? 'PUT' : 'POST', + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + body: { + message: commitMessage || getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + content: isImg ? uploadContent : utils.encodeBase64(content), + sha, + branch, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoDeleteFile + */ + async removeFile({ + token, + projectId, + branch, + path, + sha, + }) { + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { + method: 'DELETE', + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + body: { + message: getCommitMessage('deleteFileMessage', path), + sha, + branch, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoGetContents + */ + async downloadFile({ + token, + projectId, + branch, + path, + isImg, + }) { + const refreshedToken = await this.refreshToken(token); + const { sha, content } = await request(refreshedToken, { + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + return { + sha, + data: !isImg ? utils.decodeBase64(content) : content, + }; + }, +}; diff --git a/src/services/providers/helpers/giteeHelper.js b/src/services/providers/helpers/giteeHelper.js new file mode 100644 index 0000000..101050c --- /dev/null +++ b/src/services/providers/helpers/giteeHelper.js @@ -0,0 +1,423 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; + +const tokenExpirationMargin = 5 * 60 * 1000; + +const appDataRepo = 'stackedit-app-data'; + +const request = (token, options) => networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + }, + params: { + ...options.params || {}, + t: Date.now(), // Prevent from caching + access_token: token.accessToken, + }, +}); + +const repoRequest = (token, owner, repo, options) => request(token, { + ...options, + url: `https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * Getting a user from its userId is not feasible with API v3. + * Using an undocumented endpoint... + */ +const subPrefix = 'ge'; +userSvc.setInfoResolver('gitee', subPrefix, async (sub) => { + try { + const user = (await networkSvc.request({ + url: `https://gitee.com/api/v5/users/${sub}`, + params: { + t: Date.now(), // Prevent from caching + }, + })).body; + + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + return { + id: `${subPrefix}:${user.login}`, + name: user.login, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://developer.gitee.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/ + */ + async startOauth2(lastToken, silent = false, isMain, randomClientId) { + let tokenBody; + if (!silent) { + const clientId = (await networkSvc.request({ + method: 'GET', + url: 'giteeClientId', + params: { random: randomClientId }, + })).body; + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + 'https://gitee.com/oauth/authorize', + { + client_id: clientId, + scope: 'projects pull_requests', + response_type: 'code', + }, + silent, + ); + // Exchange code with token + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/giteeToken', + params: { + clientId, + code, + oauth2RedirectUri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // grant_type=refresh_token&refresh_token={refresh_token} + tokenBody = (await networkSvc.request({ + method: 'POST', + url: 'https://gitee.com/oauth/token', + params: { + grant_type: 'refresh_token', + refresh_token: lastToken.refreshToken, + }, + })).body; + } + const accessToken = tokenBody.access_token; + // Call the user info endpoint + let user = null; + try { + user = (await networkSvc.request({ + method: 'GET', + url: 'https://gitee.com/api/v5/user', + params: { + access_token: accessToken, + }, + })).body; + } catch (err) { + if (err.status === 401) { + this.startOauth2(null, false, isMain, 1); + } + throw err; + } + if (user.avatar_url && user.avatar_url.endsWith('.png') && !user.avatar_url.endsWith('no_portrait.png')) { + user.avatar_url = `${user.avatar_url}!avatar60`; + } + userSvc.addUserInfo({ + id: `${subPrefix}:${user.login}`, + name: user.login, + imageUrl: user.avatar_url || '', + }); + + // 获取同一个用户的登录token + const existingToken = store.getters['data/giteeTokensBySub'][user.login]; + + // Build token object including sub 在token失效后刷新token 如果刷新失败则触发重新授权 + const token = { + accessToken, + // 主文档空间的登录 标识登录 + isLogin: !!isMain || (existingToken && !!existingToken.isLogin), + refreshToken: tokenBody.refresh_token, + expiresOn: Date.now() + (tokenBody.expires_in * 1000), + name: user.login, + sub: `${user.login}`, + }; + if (isMain) { + token.providerId = 'giteeAppData'; + // 检查 stackedit-app-data 仓库是否已经存在 如果不存在则创建该仓库 + await this.checkAndCreateRepo(token); + } + // Add token to gitee tokens + store.dispatch('data/addGiteeToken', token); + return token; + }, + // 刷新token + async refreshToken(token) { + const { sub } = token; + const lastToken = store.getters['data/giteeTokensBySub'][sub]; + // 兼容旧的没有过期时间 + if (!lastToken.expiresOn) { + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitee', + }); + return this.startOauth2(); + } + // lastToken is not expired + if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { + return lastToken; + } + + // existing token is about to expire. + // Try to get a new token in background + try { + return await this.startOauth2(lastToken, true); + } catch (err) { + // If it fails try to popup a window + if (store.state.offline) { + throw err; + } + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitee', + }); + return this.startOauth2(); + } + }, + signin() { + return this.startOauth2(null, false, true); + }, + async addAccount() { + const token = await this.startOauth2(); + badgeSvc.addBadge('addGiteeAccount'); + return token; + }, + + /** + * https://developer.gitee.com/v3/repos/commits/#get-a-single-commit + * https://developer.gitee.com/v3/git/trees/#get-a-tree + */ + async getTree({ + token, + owner, + repo, + branch, + }) { + try { + const refreshedToken = await this.refreshToken(token); + const { commit } = await repoRequest(refreshedToken, owner, repo, { + url: `commits/${encodeURIComponent(branch)}`, + }); + const { tree, truncated } = await repoRequest(refreshedToken, owner, repo, { + url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`, + }); + if (truncated) { + throw new Error('Git tree too big. Please remove some files in the repository.'); + } + return tree; + } catch (err) { + if (err.status === 401) { + this.startOauth2(null, false, null, 1); + } + throw err; + } + }, + + async checkAndCreateRepo(token) { + const url = `https://gitee.com/api/v5/repos/${encodeURIComponent(token.name)}/${encodeURIComponent(appDataRepo)}`; + try { + await request(token, { url }); + } catch (err) { + // 不存在则创建 + if (err.status === 404) { + await request(token, { + method: 'POST', + url: 'https://gitee.com/api/v5/user/repos', + params: { + name: appDataRepo, + auto_init: true, + }, + }); + } else { + throw err; + } + } + }, + + /** + * https://developer.gitee.com/v3/repos/commits/#list-commits-on-a-repository + */ + async getCommits({ + token, + owner, + repo, + sha, + path, + }) { + const refreshedToken = await this.refreshToken(token); + return repoRequest(refreshedToken, owner, repo, { + url: 'commits', + params: { sha, path }, + }); + }, + + /** + * https://developer.gitee.com/v3/repos/contents/#create-a-file + * https://developer.gitee.com/v3/repos/contents/#update-a-file + */ + async uploadFile({ + token, + owner, + repo, + branch, + path, + content, + sha, + isImg, + commitMessage, + }) { + // 非法的文件名 不让提交 + if (!path || path.endsWith('undefined')) { + return new Promise((resolve) => { + resolve({ res: { content: { sha: null } } }); + }); + } + let uploadContent = content; + if (isImg && typeof content !== 'string') { + uploadContent = await utils.encodeFiletoBase64(content); + } + const refreshedToken = await this.refreshToken(token); + return repoRequest(refreshedToken, owner, repo, { + method: sha ? 'PUT' : 'POST', + url: `contents/${encodeURIComponent(path)}`, + body: { + message: commitMessage || getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + content: isImg ? uploadContent : utils.encodeBase64(content || ' '), + sha, + branch, + }, + }); + }, + + /** + * https://developer.gitee.com/v3/repos/contents/#delete-a-file + */ + async removeFile({ + token, + owner, + repo, + branch, + path, + sha, + }) { + const refreshedToken = await this.refreshToken(token); + return repoRequest(refreshedToken, owner, repo, { + method: 'DELETE', + url: `contents/${encodeURIComponent(path)}`, + body: { + message: getCommitMessage('deleteFileMessage', path), + sha, + branch, + }, + }); + }, + + /** + * https://developer.gitee.com/v3/repos/contents/#get-contents + */ + async downloadFile({ + token, + owner, + repo, + branch, + path, + isImg, + }) { + const refreshedToken = await this.refreshToken(token); + const { sha, content } = await repoRequest(refreshedToken, owner, repo, { + url: `contents/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + if (sha) { + const data = !isImg ? utils.decodeBase64(content) : content; + return { + sha, + data: data === ' ' ? '' : data, + }; + } + return {}; + }, + + /** + * https://gitee.com/api/v5/swagger#/postV5Gists + * https://gitee.com/api/v5/swagger#/patchV5GistsId + */ + async uploadGist({ + token, + description, + filename, + content, + isPublic, + gistId, + }) { + const { body } = await request(token, gistId ? { + method: 'PATCH', + url: `https://gitee.com/api/v5/gists/${gistId}`, + body: { + description, + files: { + [filename]: { + content, + }, + }, + }, + } : { + method: 'POST', + url: 'https://gitee.com/api/v5/gists', + body: { + description, + files: { + [filename]: { + content, + }, + }, + public: isPublic, + }, + }); + return body; + }, + + /** + * https://gitee.com/api/v5/swagger#/getV5Gists + */ + async downloadGist({ + token, + gistId, + filename, + }) { + const result = (await request(token, { + url: `https://api.github.com/gists/${gistId}`, + })).body.files[filename]; + if (!result) { + throw new Error('Gist file not found.'); + } + return result.content; + }, + + /** + * https://gitee.com/api/v5/swagger#/getV5GistsIdCommits + */ + async getGistCommits({ + token, + gistId, + }) { + const { body } = await request(token, { + url: `https://gitee.com/api/v5/gists/${gistId}/commits`, + }); + return body; + }, +}; diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js new file mode 100644 index 0000000..f186870 --- /dev/null +++ b/src/services/providers/helpers/githubHelper.js @@ -0,0 +1,441 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; + +const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; + +const appDataRepo = 'stackedit-app-data'; + +const request = (token, options) => networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + Authorization: `token ${token.accessToken}`, + }, + params: { + ...options.params || {}, + t: Date.now(), // Prevent from caching + }, +}); + +const repoRequest = (token, owner, repo, options) => request(token, { + ...options, + url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * Getting a user from its userId is not feasible with API v3. + * Using an undocumented endpoint... + */ +const subPrefix = 'gh'; +userSvc.setInfoResolver('github', subPrefix, async (sub) => { + try { + const user = (await networkSvc.request({ + url: `https://api.github.com/user/${sub}`, + params: { + t: Date.now(), // Prevent from caching + }, + })).body; + + return { + id: `${subPrefix}:${user.id}`, + name: user.login, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/ + */ + async startOauth2(scopes, sub = null, silent = false, isMain) { + await networkSvc.getServerConf(); + const clientId = store.getters['data/serverConf'].githubClientId; + + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + 'https://github.com/login/oauth/authorize', + { + client_id: clientId, + scope: scopes.join(' '), + }, + silent, + ); + + // Exchange code with token + const accessToken = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/githubToken', + params: { + clientId, + code, + }, + })).body; + + // Call the user info endpoint + const user = (await networkSvc.request({ + method: 'GET', + url: 'https://api.github.com/user', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + })).body; + userSvc.addUserInfo({ + id: `${subPrefix}:${user.id}`, + name: user.login, + imageUrl: user.avatar_url || '', + }); + + // Check the returned sub consistency + if (sub && `${user.id}` !== sub) { + throw new Error('GitHub account ID not expected.'); + } + + const oldToken = store.getters['data/githubTokensBySub'][user.id]; + // Build token object including scopes and sub + const token = { + scopes, + accessToken, + // 主文档空间的登录 标识登录 + isLogin: !!isMain || (oldToken && !!oldToken.isLogin), + name: user.login, + sub: `${user.id}`, + imgStorages: oldToken && oldToken.imgStorages, + repoFullAccess: scopes.includes('repo'), + }; + + if (isMain) { + token.providerId = 'githubAppData'; + // check stackedit-app-data repo exist? + await this.checkAndCreateRepo(token); + } + // Add token to github tokens + store.dispatch('data/addGithubToken', token); + return token; + }, + signin() { + return this.startOauth2(['repo', 'gist'], null, false, true); + }, + async addAccount(repoFullAccess = false) { + const token = await this.startOauth2(getScopes({ repoFullAccess })); + badgeSvc.addBadge('addGitHubAccount'); + return token; + }, + + /** + * https://developer.github.com/v3/repos/commits/#get-a-single-commit + * https://developer.github.com/v3/git/trees/#get-a-tree + */ + async getTree({ + token, + owner, + repo, + branch, + }) { + const { commit } = await repoRequest(token, owner, repo, { + url: `commits/${encodeURIComponent(branch)}`, + }); + const { tree, truncated } = await repoRequest(token, owner, repo, { + url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`, + }); + if (truncated) { + throw new Error('Git tree too big. Please remove some files in the repository.'); + } + return tree; + }, + + async checkAndCreateRepo(token) { + const url = `https://api.github.com/repos/${encodeURIComponent(token.name)}/${encodeURIComponent(appDataRepo)}`; + try { + await request(token, { url }); + } catch (err) { + // create + if (err.status === 404) { + await request(token, { + method: 'POST', + url: 'https://api.github.com/repos/mafgwo/stackedit-appdata-template/generate', + body: { + owner: token.name, + name: appDataRepo, + description: 'StackEdit中文版默认空间.', + include_all_branches: false, + private: true, + }, + }); + } else { + throw err; + } + } + }, + + /** + * https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository + */ + async getCommits({ + token, + owner, + repo, + sha, + path, + tryTimes, + }) { + let tryCount = tryTimes || 1; + try { + return repoRequest(token, owner, repo, { + url: 'commits', + params: { sha, path }, + }); + } catch (err) { + // 主文档 并且 409 则重试3次 + if (tryCount <= 3 && err.status === 409 && repo === appDataRepo) { + tryCount += 1; + return this.getCommits({ + token, + owner, + repo, + sha, + path, + tryTimes: tryCount, + }); + } + throw err; + } + }, + + /** + * https://developer.github.com/v3/repos/contents/#create-a-file + * https://developer.github.com/v3/repos/contents/#update-a-file + */ + async uploadFile({ + token, + owner, + repo, + branch, + path, + content, + sha, + isImg, + commitMessage, + }) { + // 非法的文件名 不让提交 + if (!path || path.endsWith('undefined')) { + return new Promise((resolve) => { + resolve({ res: { content: { sha: null } } }); + }); + } + let uploadContent = content; + if (isImg && typeof content !== 'string') { + uploadContent = await utils.encodeFiletoBase64(content); + } + return repoRequest(token, owner, repo, { + method: 'PUT', + url: `contents/${encodeURIComponent(path)}`, + body: { + message: commitMessage || getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + content: isImg ? uploadContent : utils.encodeBase64(content), + sha, + branch, + }, + }); + }, + + /** + * https://developer.github.com/v3/repos/contents/#delete-a-file + */ + async removeFile({ + token, + owner, + repo, + branch, + path, + sha, + }) { + return repoRequest(token, owner, repo, { + method: 'DELETE', + url: `contents/${encodeURIComponent(path)}`, + body: { + message: getCommitMessage('deleteFileMessage', path), + sha, + branch, + }, + }); + }, + + /** + * https://developer.github.com/v3/repos/contents/#get-contents + */ + async downloadFile({ + token, + owner, + repo, + branch, + path, + isImg, + }) { + try { + const { sha, content, encoding } = await repoRequest(token, owner, repo, { + url: `contents/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + let tempContent = content; + // 如果是图片且 encoding 为 none 则 需要获取 blob + if (isImg && encoding === 'none') { + const blobInfo = await repoRequest(token, owner, repo, { + url: `git/blobs/${sha}`, + }); + tempContent = blobInfo.content; + } + return { + sha, + data: !isImg ? utils.decodeBase64(tempContent) : tempContent, + }; + } catch (err) { + // not .stackedit-data throw err + if (err.status === 404 && path.indexOf('.stackedit-data') >= 0) { + return {}; + } + throw err; + } + }, + /** + * 获取仓库信息 + */ + async getRepoInfo(token, owner, repo) { + return request(token, { + url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + }).then(res => res.body); + }, + async updateToken(token, imgStorageInfo) { + const imgStorages = token.imgStorages || []; + // 存储仓库唯一标识 + const sid = utils.hash(`${imgStorageInfo.owner}${imgStorageInfo.repo}${imgStorageInfo.path}${imgStorageInfo.branch}`); + // 查询是否存在 存在则更新 + const filterStorages = imgStorages.filter(it => it.sid === sid); + if (filterStorages && filterStorages.length > 0) { + filterStorages.owner = imgStorageInfo.owner; + filterStorages.repo = imgStorageInfo.repo; + filterStorages.path = imgStorageInfo.path; + filterStorages.branch = imgStorageInfo.branch; + } else { + imgStorages.push({ + sid, + owner: imgStorageInfo.owner, + repo: imgStorageInfo.repo, + path: imgStorageInfo.path, + branch: imgStorageInfo.branch, + }); + token.imgStorages = imgStorages; + } + store.dispatch('data/addGithubToken', token); + }, + async removeTokenImgStorage(token, sid) { + if (!token.imgStorages || token.imgStorages.length === 0) { + return; + } + token.imgStorages = token.imgStorages.filter(it => it.sid !== sid); + store.dispatch('data/addGithubToken', token); + }, + + /** + * https://developer.github.com/v3/gists/#create-a-gist + * https://developer.github.com/v3/gists/#edit-a-gist + */ + async uploadGist({ + token, + description, + filename, + content, + isPublic, + gistId, + }) { + const { body } = await request(token, gistId ? { + method: 'PATCH', + url: `https://api.github.com/gists/${gistId}`, + body: { + description, + files: { + [filename]: { + content, + }, + }, + }, + } : { + method: 'POST', + url: 'https://api.github.com/gists', + body: { + description, + files: { + [filename]: { + content, + }, + }, + public: isPublic, + }, + }); + return body; + }, + + /** + * https://developer.github.com/v3/gists/#get-a-single-gist + */ + async downloadGist({ + token, + gistId, + filename, + }) { + const result = (await request(token, { + url: `https://api.github.com/gists/${gistId}`, + })).body.files[filename]; + if (!result) { + throw new Error('Gist file not found.'); + } + return result.content; + }, + + /** + * https://developer.github.com/v3/gists/#list-gist-commits + */ + async getGistCommits({ + token, + gistId, + }) { + const { body } = await request(token, { + url: `https://api.github.com/gists/${gistId}/commits`, + }); + return body; + }, + + /** + * https://developer.github.com/v3/gists/#get-a-specific-revision-of-a-gist + */ + async downloadGistRevision({ + token, + gistId, + filename, + sha, + }) { + const result = (await request(token, { + url: `https://api.github.com/gists/${gistId}/${sha}`, + })).body.files[filename]; + if (!result) { + throw new Error('Gist file not found.'); + } + return result.content; + }, +}; diff --git a/src/services/providers/helpers/gitlabHelper.js b/src/services/providers/helpers/gitlabHelper.js new file mode 100644 index 0000000..c8725d3 --- /dev/null +++ b/src/services/providers/helpers/gitlabHelper.js @@ -0,0 +1,347 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; + +const tokenExpirationMargin = 5 * 60 * 1000; + +const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ + ...options, + url: `${serverUrl}/api/v4/${options.url}`, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${accessToken}`, + }, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * https://docs.gitlab.com/ee/api/users.html#for-user + */ +const subPrefix = 'gl'; +userSvc.setInfoResolver('gitlab', subPrefix, async (sub) => { + try { + const [, serverUrl, id] = sub.match(/^(.+)\/([^/]+)$/); + const user = (await networkSvc.request({ + url: `${serverUrl}/api/v4/users/${id}`, + })).body; + const uniqueSub = `${serverUrl}/${user.id}`; + + return { + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://docs.gitlab.com/ee/api/oauth2.html + */ + async startOauth2( + serverUrl, applicationId, applicationSecret, + sub = null, silent = false, refreshToken, + ) { + let apiUrl = serverUrl; + let clientId = applicationId; + let useServerConf = false; + // 获取gitlab配置的参数 + await networkSvc.getServerConf(); + const confClientId = store.getters['data/serverConf'].gitlabClientId; + const confServerUrl = store.getters['data/serverConf'].gitlabUrl; + // 存在gitlab配置则使用后端配置 + if (confClientId && confServerUrl) { + apiUrl = confServerUrl; + clientId = confClientId; + useServerConf = true; + } + let tokenBody; + if (!silent) { + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + `${apiUrl}/oauth/authorize`, + { + client_id: clientId, + response_type: 'code', + redirect_uri: constants.oauth2RedirectUri, + }, + silent, + ); + if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/gitlabToken', + params: { + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange code with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/oauth/token`, + params: { + client_id: clientId, + client_secret: applicationSecret, + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } + } else if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/gitlabToken', + params: { + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange refreshToken with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/oauth/token`, + body: { + client_id: clientId, + client_secret: applicationSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } + + const accessToken = tokenBody.access_token; + // Call the user info endpoint + const user = await request({ accessToken, serverUrl: apiUrl }, { + url: 'user', + }); + const uniqueSub = `${apiUrl}/${user.id}`; + userSvc.addUserInfo({ + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }); + + // Check the returned sub consistency + if (sub && uniqueSub !== sub) { + throw new Error('GitLab account ID not expected.'); + } + + const oldToken = store.getters['data/gitlabTokensBySub'][uniqueSub]; + // Build token object including scopes and sub + const token = { + accessToken, + name: user.username, + applicationId: clientId, + applicationSecret, + imgStorages: oldToken && oldToken.imgStorages, + refreshToken: tokenBody.refresh_token, + expiresOn: Date.now() + ((tokenBody.expires_in || 7200) * 1000), + serverUrl: apiUrl, + sub: uniqueSub, + }; + + // Add token to gitlab tokens + store.dispatch('data/addGitlabToken', token); + return token; + }, + async addAccount(serverUrl, applicationId, applicationSecret, sub = null) { + const token = await this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + badgeSvc.addBadge('addGitLabAccount'); + return token; + }, + // 刷新token + async refreshToken(token) { + const { + serverUrl, + applicationId, + applicationSecret, + sub, + } = token; + const lastToken = store.getters['data/gitlabTokensBySub'][sub]; + // 兼容旧的没有过期时间 + if (!lastToken.expiresOn || !lastToken.refreshToken) { + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitlab', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + // lastToken is not expired + if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { + return lastToken; + } + + // existing token is about to expire. + // Try to get a new token in background + try { + return await this.startOauth2( + serverUrl, applicationId, applicationSecret, + sub, true, lastToken.refreshToken, + ); + } catch (err) { + // If it fails try to popup a window + if (store.state.offline) { + throw err; + } + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitlab', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + }, + // 带刷新token + async requestWithRefreshToken(token, options) { + const refreshedToken = await this.refreshToken(token); + const result = await request(refreshedToken, options); + return result; + }, + /** + * https://docs.gitlab.com/ee/api/projects.html#get-single-project + */ + async getProjectId(token, { projectPath, projectId }) { + if (projectId) { + return projectId; + } + const project = await this.requestWithRefreshToken(token, { + url: `projects/${encodeURIComponent(projectPath)}`, + }); + return project.id; + }, + + /** + * https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree + */ + async getTree({ + token, + projectId, + branch, + }) { + return this.requestWithRefreshToken(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/tree`, + params: { + ref: branch, + recursive: true, + per_page: 9999, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/commits.html#list-repository-commits + */ + async getCommits({ + token, + projectId, + branch, + path, + }) { + return this.requestWithRefreshToken(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/commits`, + params: { + ref_name: branch, + path, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository + * https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository + */ + async uploadFile({ + token, + projectId, + branch, + path, + content, + sha, + isImg, + commitMessage, + }) { + // 非法的文件名 不让提交 + if (!path || path.endsWith('undefined')) { + return new Promise((resolve) => { + resolve({ res: { content: { sha: null } } }); + }); + } + let uploadContent = content; + if (isImg && typeof content !== 'string') { + uploadContent = await utils.encodeFiletoBase64(content); + } + return this.requestWithRefreshToken(token, { + method: sha ? 'PUT' : 'POST', + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + body: { + commit_message: commitMessage || getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + encoding: 'base64', + content: isImg ? uploadContent : utils.encodeBase64(content), + last_commit_id: sha, + branch, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#delete-existing-file-in-repository + */ + async removeFile({ + token, + projectId, + branch, + path, + sha, + }) { + return this.requestWithRefreshToken(token, { + method: 'DELETE', + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + body: { + commit_message: getCommitMessage('deleteFileMessage', path), + last_commit_id: sha, + branch, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#get-file-from-repository + */ + async downloadFile({ + token, + projectId, + branch, + path, + isImg, + }) { + const res = await this.requestWithRefreshToken(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + return { + sha: res.last_commit_id, + data: !isImg ? utils.decodeBase64(res.content) : res.content, + }; + }, +}; diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js new file mode 100644 index 0000000..f9a8037 --- /dev/null +++ b/src/services/providers/helpers/googleHelper.js @@ -0,0 +1,701 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; + +const appsDomain = null; +const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (tokens expire after 1h) + +const driveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata']; +const getDriveScopes = token => [token.driveFullAccess + ? 'https://www.googleapis.com/auth/drive' + : 'https://www.googleapis.com/auth/drive.file']; +const bloggerScopes = ['https://www.googleapis.com/auth/blogger']; +const photosScopes = ['https://www.googleapis.com/auth/photos']; + +const checkIdToken = (idToken) => { + try { + const token = idToken.split('.'); + const payload = JSON.parse(utils.decodeBase64(token[1])); + const clientId = store.getters['data/serverConf'].googleClientId; + return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000; + } catch (e) { + return false; + } +}; + +let driveState; +if (utils.queryParams.providerId === 'googleDrive') { + try { + driveState = JSON.parse(utils.queryParams.state); + } catch (e) { + // Ignore + } +} + +/** + * https://developers.google.com/people/api/rest/v1/people/get + */ +const getUser = async (sub, token) => { + const apiKey = store.getters['data/serverConf'].googleApiKey; + const url = `https://people.googleapis.com/v1/people/${sub}?personFields=names,photos&key=${apiKey}`; + const { body } = await networkSvc.request(sub === 'me' && token + ? { + method: 'GET', + url, + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + } + : { + method: 'GET', + url, + }, true); + return body; +}; + +const subPrefix = 'go'; +userSvc.setInfoResolver('google', subPrefix, async (sub) => { + try { + const googleToken = Object.values(store.getters['data/googleTokensBySub'])[0]; + const body = await getUser(sub, googleToken); + const name = (body.names && body.names[0]) || {}; + const photo = (body.photos && body.photos[0]) || {}; + return { + id: `${subPrefix}:${sub}`, + name: name.displayName, + imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'), + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + folderMimeType: 'application/vnd.google-apps.folder', + driveState, + driveActionFolder: null, + driveActionFiles: [], + async $request(token, options) { + try { + return (await networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${token.accessToken}`, + }, + }, true)).body; + } catch (err) { + const { reason } = (((err.body || {}).error || {}).errors || [])[0] || {}; + if (reason === 'authError') { + // Mark the token as revoked and get a new one + store.dispatch('data/addGoogleToken', { + ...token, + expiresOn: 0, + }); + // Refresh token and retry + const refreshedToken = await this.refreshToken(token, token.scopes); + return this.$request(refreshedToken, options); + } + throw err; + } + }, + + /** + * https://developers.google.com/identity/protocols/OpenIDConnect + */ + async startOauth2(scopes, sub = null, silent = false) { + await networkSvc.getServerConf(); + const clientId = store.getters['data/serverConf'].googleClientId; + + // Get an OAuth2 code + const { accessToken, expiresIn, idToken } = await networkSvc.startOauth2( + 'https://accounts.google.com/o/oauth2/v2/auth', + { + client_id: clientId, + response_type: 'token id_token', + scope: ['openid', 'profile', ...scopes].join(' '), + hd: appsDomain, + login_hint: sub, + prompt: silent ? 'none' : null, + nonce: utils.uid(), + }, + silent, + ); + + // Call the token info endpoint + const { body } = await networkSvc.request({ + method: 'POST', + url: 'https://www.googleapis.com/oauth2/v3/tokeninfo', + params: { + access_token: accessToken, + }, + }, true); + + // Check the returned client ID consistency + if (body.aud !== clientId) { + throw new Error('Client ID inconsistent.'); + } + // Check the returned sub consistency + if (sub && `${body.sub}` !== sub) { + throw new Error('Google account ID not expected.'); + } + + // Build token object including scopes and sub + const existingToken = store.getters['data/googleTokensBySub'][body.sub]; + const token = { + scopes, + accessToken, + expiresOn: Date.now() + (expiresIn * 1000), + idToken, + sub: body.sub, + name: (existingToken || {}).name || 'Someone', + isLogin: !store.getters['workspace/mainWorkspaceToken'] && + scopes.includes('https://www.googleapis.com/auth/drive.appdata'), + isSponsor: false, + isDrive: scopes.includes('https://www.googleapis.com/auth/drive') || + scopes.includes('https://www.googleapis.com/auth/drive.file'), + isBlogger: scopes.includes('https://www.googleapis.com/auth/blogger'), + isPhotos: scopes.includes('https://www.googleapis.com/auth/photos'), + driveFullAccess: scopes.includes('https://www.googleapis.com/auth/drive'), + }; + + // Call the user info endpoint + const user = await getUser('me', token); + const userId = user.resourceName.split('/')[1]; + const name = user.names[0] || {}; + const photo = user.photos[0] || {}; + if (name.displayName) { + token.name = name.displayName; + } + userSvc.addUserInfo({ + id: `${subPrefix}:${userId}`, + name: name.displayName, + imageUrl: (photo.url || '').replace(/\bsz?=\d+$/, 'sz=40'), + }); + + if (existingToken) { + // We probably retrieved a new token with restricted scopes. + // That's no problem, token will be refreshed later with merged scopes. + // Restore flags + Object.assign(token, { + isLogin: existingToken.isLogin || token.isLogin, + isSponsor: existingToken.isSponsor, + isDrive: existingToken.isDrive || token.isDrive, + isBlogger: existingToken.isBlogger || token.isBlogger, + isPhotos: existingToken.isPhotos || token.isPhotos, + driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess, + }); + } + + if (token.isLogin) { + try { + const res = await networkSvc.request({ + method: 'GET', + url: 'userInfo', + params: { + idToken: token.idToken, + }, + }); + token.isSponsor = res.body.sponsorUntil > Date.now(); + if (token.isSponsor) { + badgeSvc.addBadge('sponsor'); + } + } catch (err) { + // Ignore + } + } + + // Add token to google tokens + await store.dispatch('data/addGoogleToken', token); + return token; + }, + async refreshToken(token, scopes = []) { + const { sub } = token; + const lastToken = store.getters['data/googleTokensBySub'][sub]; + const mergedScopes = [...new Set([ + ...scopes, + ...lastToken.scopes, + ])]; + + if ( + // If we already have permissions for the requested scopes + mergedScopes.length === lastToken.scopes.length && + // And lastToken is not expired + lastToken.expiresOn > Date.now() + tokenExpirationMargin && + // And in case of a login token, ID token is still valid + (!lastToken.isLogin || checkIdToken(lastToken.idToken)) + ) { + return lastToken; + } + + // New scopes are requested or existing token is about to expire. + // Try to get a new token in background + try { + return await this.startOauth2(mergedScopes, sub, true); + } catch (err) { + // If it fails try to popup a window + if (store.state.offline) { + throw err; + } + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Google', + }); + return this.startOauth2(mergedScopes, sub); + } + }, + signin() { + return this.startOauth2(driveAppDataScopes); + }, + async addDriveAccount(fullAccess = false, sub = null) { + const token = await this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }), sub); + badgeSvc.addBadge('addGoogleDriveAccount'); + return token; + }, + async addBloggerAccount() { + const token = await this.startOauth2(bloggerScopes); + badgeSvc.addBadge('addBloggerAccount'); + return token; + }, + async addPhotosAccount() { + const token = await this.startOauth2(photosScopes); + badgeSvc.addBadge('addGooglePhotosAccount'); + return token; + }, + + /** + * https://developers.google.com/drive/v3/reference/files/create + * https://developers.google.com/drive/v3/reference/files/update + * https://developers.google.com/drive/v3/web/simple-upload + */ + async $uploadFile({ + refreshedToken, + name, + parents, + appProperties, + media = null, + mediaType = null, + fileId = null, + oldParents = null, + ifNotTooLate = cb => cb(), + }) { + // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late + return ifNotTooLate(() => { + const options = { + method: 'POST', + url: 'https://www.googleapis.com/drive/v3/files', + }; + const params = { + supportsTeamDrives: true, + }; + const metadata = { name, appProperties }; + if (fileId) { + options.method = 'PATCH'; + options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; + if (parents && oldParents) { + params.addParents = parents + .filter(parent => !oldParents.includes(parent)) + .join(','); + params.removeParents = oldParents + .filter(parent => !parents.includes(parent)) + .join(','); + } + } else if (parents) { + metadata.parents = parents; + } + if (media) { + const boundary = `-------${utils.uid()}`; + const delimiter = `\r\n--${boundary}\r\n`; + const closeDelimiter = `\r\n--${boundary}--`; + let multipartRequestBody = ''; + multipartRequestBody += delimiter; + multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; + multipartRequestBody += JSON.stringify(metadata); + multipartRequestBody += delimiter; + multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\r\n\r\n`; + multipartRequestBody += media; + multipartRequestBody += closeDelimiter; + options.url = options.url.replace( + 'https://www.googleapis.com/', + 'https://www.googleapis.com/upload/', + ); + return this.$request(refreshedToken, { + ...options, + params: { + ...params, + uploadType: 'multipart', + }, + headers: { + 'Content-Type': `multipart/mixed; boundary="${boundary}"`, + }, + body: multipartRequestBody, + }); + } + if (mediaType) { + metadata.mimeType = mediaType; + } + return this.$request(refreshedToken, { + ...options, + body: metadata, + params, + }); + }); + }, + async uploadFile({ + token, + name, + parents, + appProperties, + media, + mediaType, + fileId, + oldParents, + ifNotTooLate, + }) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$uploadFile({ + refreshedToken, + name, + parents, + appProperties, + media, + mediaType, + fileId, + oldParents, + ifNotTooLate, + }); + }, + async uploadAppDataFile({ + token, + name, + media, + fileId, + ifNotTooLate, + }) { + const refreshedToken = await this.refreshToken(token, driveAppDataScopes); + return this.$uploadFile({ + refreshedToken, + name, + parents: ['appDataFolder'], + media, + fileId, + ifNotTooLate, + }); + }, + + /** + * https://developers.google.com/drive/v3/reference/files/get + */ + async getFile(token, id) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}`, + params: { + fields: 'id,name,mimeType,appProperties,teamDriveId', + supportsTeamDrives: true, + }, + }); + }, + + /** + * https://developers.google.com/drive/v3/web/manage-downloads + */ + async $downloadFile(refreshedToken, id) { + return this.$request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`, + raw: true, + }); + }, + async downloadFile(token, id) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$downloadFile(refreshedToken, id); + }, + async downloadAppDataFile(token, id) { + const refreshedToken = await this.refreshToken(token, driveAppDataScopes); + return this.$downloadFile(refreshedToken, id); + }, + + /** + * https://developers.google.com/drive/v3/reference/files/delete + */ + async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) { + // Refreshing a token can take a while if an oauth window pops up, so check if it's too late + return ifNotTooLate(() => this.$request(refreshedToken, { + method: 'DELETE', + url: `https://www.googleapis.com/drive/v3/files/${id}`, + params: { + supportsTeamDrives: true, + }, + })); + }, + async removeFile(token, id, ifNotTooLate) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$removeFile(refreshedToken, id, ifNotTooLate); + }, + async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) { + const refreshedToken = await this.refreshToken(token, driveAppDataScopes); + return this.$removeFile(refreshedToken, id, ifNotTooLate); + }, + + /** + * https://developers.google.com/drive/v3/reference/revisions/list + */ + async $getFileRevisions(refreshedToken, id) { + const allRevisions = []; + const getPage = async (pageToken) => { + const { revisions, nextPageToken } = await this.$request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`, + params: { + pageToken, + pageSize: 1000, + fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)', + }, + }); + revisions.forEach((revision) => { + userSvc.addUserInfo({ + id: `${subPrefix}:${revision.lastModifyingUser.permissionId}`, + name: revision.lastModifyingUser.displayName, + imageUrl: revision.lastModifyingUser.photoLink || '', + }); + allRevisions.push(revision); + }); + if (nextPageToken) { + return getPage(nextPageToken); + } + return allRevisions; + }; + return getPage(); + }, + async getFileRevisions(token, id) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$getFileRevisions(refreshedToken, id); + }, + async getAppDataFileRevisions(token, id) { + const refreshedToken = await this.refreshToken(token, driveAppDataScopes); + return this.$getFileRevisions(refreshedToken, id); + }, + + /** + * https://developers.google.com/drive/v3/reference/revisions/get + */ + async $downloadFileRevision(refreshedToken, id, revisionId) { + return this.$request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}/revisions/${revisionId}?alt=media`, + raw: true, + }); + }, + async downloadFileRevision(token, fileId, revisionId) { + const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); + return this.$downloadFileRevision(refreshedToken, fileId, revisionId); + }, + async downloadAppDataFileRevision(token, fileId, revisionId) { + const refreshedToken = await this.refreshToken(token, driveAppDataScopes); + return this.$downloadFileRevision(refreshedToken, fileId, revisionId); + }, + + /** + * https://developers.google.com/drive/v3/reference/changes/list + */ + async getChanges(token, startPageToken, isAppData, teamDriveId = null) { + const result = { + changes: [], + }; + let fileFields = 'file/name'; + if (!isAppData) { + fileFields += ',file/parents,file/mimeType,file/appProperties'; + } + const refreshedToken = await this.refreshToken( + token, + isAppData ? driveAppDataScopes : getDriveScopes(token), + ); + + const getPage = async (pageToken = '1') => { + const { changes, nextPageToken, newStartPageToken } = await this.$request(refreshedToken, { + method: 'GET', + url: 'https://www.googleapis.com/drive/v3/changes', + params: { + pageToken, + spaces: isAppData ? 'appDataFolder' : 'drive', + pageSize: 1000, + fields: `nextPageToken,newStartPageToken,changes(fileId,${fileFields})`, + supportsTeamDrives: true, + includeTeamDriveItems: !!teamDriveId, + teamDriveId, + }, + }); + result.changes = [...result.changes, ...changes.filter(item => item.fileId)]; + if (nextPageToken) { + return getPage(nextPageToken); + } + result.startPageToken = newStartPageToken; + return result; + }; + return getPage(startPageToken); + }, + + /** + * https://developers.google.com/blogger/docs/3.0/reference/blogs/getByUrl + * https://developers.google.com/blogger/docs/3.0/reference/posts/insert + * https://developers.google.com/blogger/docs/3.0/reference/posts/update + */ + async uploadBlogger({ + token, + blogUrl, + blogId, + postId, + title, + content, + labels, + isDraft, + published, + isPage, + }) { + const refreshedToken = await this.refreshToken(token, bloggerScopes); + + // Get the blog ID + const blog = { id: blogId }; + if (!blog.id) { + blog.id = (await this.$request(refreshedToken, { + url: 'https://www.googleapis.com/blogger/v3/blogs/byurl', + params: { + url: blogUrl, + }, + })).id; + } + + // Create/update the post/page + const path = isPage ? 'pages' : 'posts'; + let options = { + method: 'POST', + url: `https://www.googleapis.com/blogger/v3/blogs/${blog.id}/${path}/`, + body: { + kind: isPage ? 'blogger#page' : 'blogger#post', + blog, + title, + content, + }, + }; + if (labels) { + options.body.labels = labels; + } + if (published) { + options.body.published = published.toISOString(); + } + // If it's an update + if (postId) { + options.method = 'PUT'; + options.url += postId; + options.body.id = postId; + } + const post = await this.$request(refreshedToken, options); + if (isPage) { + return post; + } + + // Revert/publish post + options = { + method: 'POST', + url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`, + params: {}, + }; + if (isDraft) { + options.url += 'revert'; + } else { + options.url += 'publish'; + if (published) { + options.params.publishDate = published.toISOString(); + } + } + return this.$request(refreshedToken, options); + }, + + /** + * https://developers.google.com/picker/docs/ + */ + async openPicker(token, type = 'doc') { + const scopes = type === 'img' ? photosScopes : getDriveScopes(token); + if (!window.google) { + await networkSvc.loadScript('https://apis.google.com/js/api.js'); + await new Promise((resolve, reject) => window.gapi.load('picker', { + callback: resolve, + onerror: reject, + timeout: 30000, + ontimeout: reject, + })); + } + const refreshedToken = await this.refreshToken(token, scopes); + const { google } = window; + return new Promise((resolve) => { + let picker; + const pickerBuilder = new google.picker.PickerBuilder() + .setOAuthToken(refreshedToken.accessToken) + .enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES) + .hideTitleBar() + .setCallback((data) => { + switch (data[google.picker.Response.ACTION]) { + case google.picker.Action.PICKED: + case google.picker.Action.CANCEL: + resolve(data.docs || []); + picker.dispose(); + break; + default: + } + }); + switch (type) { + default: + case 'doc': { + const mimeTypes = [ + 'text/plain', + 'text/x-markdown', + 'application/octet-stream', + ].join(','); + + const view = new google.picker.DocsView(google.picker.ViewId.DOCS); + view.setMimeTypes(mimeTypes); + pickerBuilder.addView(view); + + const teamDriveView = new google.picker.DocsView(google.picker.ViewId.DOCS); + teamDriveView.setMimeTypes(mimeTypes); + teamDriveView.setEnableTeamDrives(true); + pickerBuilder.addView(teamDriveView); + + pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED); + pickerBuilder.enableFeature(google.picker.Feature.SUPPORT_TEAM_DRIVES); + break; + } + case 'folder': { + const folderView = new google.picker.DocsView(google.picker.ViewId.FOLDERS); + folderView.setSelectFolderEnabled(true); + folderView.setMimeTypes(this.folderMimeType); + pickerBuilder.addView(folderView); + + const teamDriveView = new google.picker.DocsView(google.picker.ViewId.FOLDERS); + teamDriveView.setSelectFolderEnabled(true); + teamDriveView.setEnableTeamDrives(true); + teamDriveView.setMimeTypes(this.folderMimeType); + pickerBuilder.addView(teamDriveView); + break; + } + case 'img': { + const view = new google.picker.PhotosView(); + view.setType('highlights'); + pickerBuilder.addView(view); + pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD); + break; + } + } + picker = pickerBuilder.build(); + picker.setVisible(true); + }); + }, +}; diff --git a/src/services/providers/helpers/smmsHelper.js b/src/services/providers/helpers/smmsHelper.js new file mode 100644 index 0000000..7de6bc8 --- /dev/null +++ b/src/services/providers/helpers/smmsHelper.js @@ -0,0 +1,76 @@ +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; + +/** + * https://doc.sm.ms/#api-User-Get_Profile + */ +const subPrefix = 'sm'; +export default { + subPrefix, + async getTokenObj(proxyUrl, apiSecretToken) { + // Call the user info endpoint + try { + const { body } = await networkSvc.request({ + method: 'POST', + url: `${proxyUrl}https://sm.ms/api/v2/profile`, + headers: { + Authorization: apiSecretToken, + }, + }); + // Check user result + if (!body.success) { + throw new Error(`SM.MS个人信息获取失败,失败信息:${body.message}`); + } + userSvc.addUserInfo({ + id: `${subPrefix}:${body.data.username}`, + name: body.data.username, + imageUrl: 'https://gravatar.loli.net/avatar/ccc459536d65637c192c00f639569864', + }); + // Build token object including sub + const token = { + proxyUrl, + accessToken: apiSecretToken, + name: body.data.username, + sub: body.data.username, + }; + // Add token to smms tokens + store.dispatch('data/addSmmsToken', token); + return token; + } catch (err) { + console.error(err); // eslint-disable-line no-console + store.dispatch('notification/error', err); + throw new Error(`SM.MS个人信息获取异常,异常信息:${err.message}`); + } + }, + async addAccount(proxyUrl, apiSecretToken) { + const token = await this.getTokenObj(proxyUrl, apiSecretToken); + badgeSvc.addBadge('addSmmsAccount'); + return token; + }, + async uploadFile({ + token, + file, + }) { + const { body } = await networkSvc.request({ + method: 'POST', + url: `${token.proxyUrl}https://sm.ms/api/v2/upload`, + headers: { + Authorization: token.accessToken, + }, + formData: { + smfile: file, + }, + }); + if (!body.success) { + if (body.code === 'image_repeated') { + return body.images; + } + store.dispatch('notification/error', `SM.MS上传图片失败,失败信息:${body.message}`); + throw new Error(`SM.MS上传图片失败,失败信息:${body.message}`); + } + return body.data.url; + }, + +}; diff --git a/src/services/providers/helpers/wordpressHelper.js b/src/services/providers/helpers/wordpressHelper.js new file mode 100644 index 0000000..c109bfd --- /dev/null +++ b/src/services/providers/helpers/wordpressHelper.js @@ -0,0 +1,112 @@ +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import badgeSvc from '../../badgeSvc'; + +const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks) + +const request = (token, options) => networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${token.accessToken}`, + }, +}) + .then(res => res.body); + +export default { + /** + * https://developer.wordpress.com/docs/oauth2/ + */ + async startOauth2(sub = null, silent = false) { + await networkSvc.getServerConf(); + const clientId = store.getters['data/serverConf'].wordpressClientId; + + // Get an OAuth2 code + const { accessToken, expiresIn } = await networkSvc.startOauth2( + 'https://public-api.wordpress.com/oauth2/authorize', + { + client_id: clientId, + response_type: 'token', + scope: 'global', + }, + silent, + ); + + // Call the user info endpoint + const body = await request({ accessToken }, { + url: 'https://public-api.wordpress.com/rest/v1.1/me', + }); + + // Check the returned sub consistency + if (sub && `${body.ID}` !== sub) { + throw new Error('WordPress account ID not expected.'); + } + // Build token object including scopes and sub + const token = { + accessToken, + expiresOn: Date.now() + (expiresIn * 1000), + name: body.display_name, + sub: `${body.ID}`, + }; + // Add token to wordpress tokens + store.dispatch('data/addWordpressToken', token); + return token; + }, + async refreshToken(token) { + const { sub } = token; + const lastToken = store.getters['data/wordpressTokensBySub'][sub]; + + if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { + return lastToken; + } + // Existing token is going to expire. + // Try to get a new token in background + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'WordPress', + }); + return this.startOauth2(sub); + }, + async addAccount(fullAccess = false) { + const token = await this.startOauth2(fullAccess); + badgeSvc.addBadge('addWordpressAccount'); + return token; + }, + + /** + * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/new/ + * https://developer.wordpress.com/docs/api/1.2/post/sites/%24site/posts/%24post_ID/ + */ + async uploadPost({ + token, + domain, + siteId, + postId, + title, + content, + tags, + categories, + excerpt, + author, + featuredImage, + status, + date, + }) { + const refreshedToken = await this.refreshToken(token); + return request(refreshedToken, { + method: 'POST', + url: `https://public-api.wordpress.com/rest/v1.2/sites/${siteId || domain}/posts/${postId || 'new'}`, + body: { + content, + title, + tags, + categories, + excerpt, + author, + featured_image: featuredImage || '', + status, + date: date && date.toISOString(), + }, + }); + }, +}; diff --git a/src/services/providers/helpers/zendeskHelper.js b/src/services/providers/helpers/zendeskHelper.js new file mode 100644 index 0000000..2d50d08 --- /dev/null +++ b/src/services/providers/helpers/zendeskHelper.js @@ -0,0 +1,114 @@ +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import badgeSvc from '../../badgeSvc'; + +const request = (token, options) => networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${token.accessToken}`, + }, +}) + .then(res => res.body); + + +export default { + /** + * https://support.zendesk.com/hc/en-us/articles/203663836-Using-OAuth-authentication-with-your-application + */ + async startOauth2(subdomain, clientId, sub = null, silent = false) { + // Get an OAuth2 code + const { accessToken } = await networkSvc.startOauth2( + `https://${subdomain}.zendesk.com/oauth/authorizations/new`, + { + client_id: clientId, + response_type: 'token', + scope: 'read hc:write', + }, + silent, + ); + + // Call the user info endpoint + const { user } = await request({ accessToken }, { + url: `https://${subdomain}.zendesk.com/api/v2/users/me.json`, + }); + const uniqueSub = `${subdomain}/${user.id}`; + + // Check the returned sub consistency + if (sub && uniqueSub !== sub) { + throw new Error('Zendesk account ID not expected.'); + } + + // Build token object including scopes and sub + const token = { + accessToken, + name: user.name, + subdomain, + sub: uniqueSub, + }; + + // Add token to zendesk tokens + store.dispatch('data/addZendeskToken', token); + return token; + }, + async addAccount(subdomain, clientId) { + const token = await this.startOauth2(subdomain, clientId); + badgeSvc.addBadge('addZendeskAccount'); + return token; + }, + + /** + * https://developer.zendesk.com/rest_api/docs/help_center/articles + */ + async uploadArticle({ + token, + sectionId, + articleId, + title, + content, + labels, + locale, + isDraft, + }) { + const article = { + title, + body: content, + locale, + draft: isDraft, + }; + + if (articleId) { + // Update article + await request(token, { + method: 'PUT', + url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}/translations/${locale}.json`, + body: { translation: article }, + }); + + // Add labels + if (labels) { + await request(token, { + method: 'PUT', + url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/articles/${articleId}.json`, + body: { + article: { + label_names: labels, + }, + }, + }); + } + return articleId; + } + + // Create new article + if (labels) { + article.label_names = labels; + } + const body = await request(token, { + method: 'POST', + url: `https://${token.subdomain}.zendesk.com/api/v2/help_center/sections/${sectionId}/articles.json`, + body: { article }, + }); + return `${body.article.id}`; + }, +}; diff --git a/src/services/providers/wordpressProvider.js b/src/services/providers/wordpressProvider.js new file mode 100644 index 0000000..a244e85 --- /dev/null +++ b/src/services/providers/wordpressProvider.js @@ -0,0 +1,41 @@ +import store from '../../store'; +import wordpressHelper from './helpers/wordpressHelper'; +import Provider from './common/Provider'; + +export default new Provider({ + id: 'wordpress', + name: 'WordPress', + getToken({ sub }) { + return store.getters['data/wordpressTokensBySub'][sub]; + }, + getLocationUrl({ siteId, postId }) { + return `https://wordpress.com/post/${siteId}/${postId}`; + }, + getLocationDescription({ postId }) { + return postId; + }, + async publish(token, html, metadata, publishLocation) { + const post = await wordpressHelper.uploadPost({ + ...publishLocation, + ...metadata, + token, + content: html, + }); + return { + ...publishLocation, + siteId: `${post.site_ID}`, + postId: `${post.ID}`, + }; + }, + makeLocation(token, domain, postId) { + const location = { + providerId: this.id, + sub: token.sub, + domain, + }; + if (postId) { + location.postId = postId; + } + return location; + }, +}); diff --git a/src/services/providers/zendeskProvider.js b/src/services/providers/zendeskProvider.js new file mode 100644 index 0000000..27b6066 --- /dev/null +++ b/src/services/providers/zendeskProvider.js @@ -0,0 +1,44 @@ +import store from '../../store'; +import zendeskHelper from './helpers/zendeskHelper'; +import Provider from './common/Provider'; + +export default new Provider({ + id: 'zendesk', + name: 'Zendesk', + getToken({ sub }) { + return store.getters['data/zendeskTokensBySub'][sub]; + }, + getLocationUrl({ sub, locale, articleId }) { + const token = this.getToken({ sub }); + return `https://${token.subdomain}.zendesk.com/hc/${locale}/articles/${articleId}`; + }, + getLocationDescription({ articleId }) { + return articleId; + }, + async publish(token, html, metadata, publishLocation) { + const articleId = await zendeskHelper.uploadArticle({ + ...publishLocation, + token, + title: metadata.title, + content: html, + labels: metadata.tags, + isDraft: metadata.status === 'draft', + }); + return { + ...publishLocation, + articleId, + }; + }, + makeLocation(token, sectionId, locale, articleId) { + const location = { + providerId: this.id, + sub: token.sub, + sectionId, + locale, + }; + if (articleId) { + location.articleId = articleId; + } + return location; + }, +}); diff --git a/src/services/publishSvc.js b/src/services/publishSvc.js new file mode 100644 index 0000000..ab6a542 --- /dev/null +++ b/src/services/publishSvc.js @@ -0,0 +1,179 @@ +import localDbSvc from './localDbSvc'; +import store from '../store'; +import utils from './utils'; +import networkSvc from './networkSvc'; +import exportSvc from './exportSvc'; +import providerRegistry from './providers/common/providerRegistry'; +import workspaceSvc from './workspaceSvc'; +import badgeSvc from './badgeSvc'; + +const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length; + +const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) + // Item does not exist, create it + .catch(() => store.commit(`${type}/setItem`, { + id: `${fileId}/${type}`, + })); +const loadContent = loader('content'); + +const ensureArray = (value) => { + if (!value) { + return []; + } + if (!Array.isArray(value)) { + return `${value}`.trim().split(/\s*,\s*/); + } + return value; +}; + +const ensureString = (value, defaultValue) => { + if (!value) { + return defaultValue; + } + return `${value}`; +}; + +const ensureDate = (value, defaultValue) => { + if (!value) { + return defaultValue; + } + return new Date(`${value}`); +}; + +// git 相关的 providerId +const gitProviderIds = ['gitea', 'gitee', 'github', 'gitlab']; + +const publish = async (publishLocation, commitMessage) => { + const { fileId } = publishLocation; + const template = store.getters['data/allTemplatesById'][publishLocation.templateId]; + const html = await exportSvc.applyTemplate(fileId, template); + const content = await localDbSvc.loadItem(`${fileId}/content`); + const file = store.state.file.itemsById[fileId]; + const properties = utils.computeProperties(content.properties); + const provider = providerRegistry.providersById[publishLocation.providerId]; + const token = provider.getToken(publishLocation); + const metadata = { + title: ensureString(properties.title, file.name), + author: ensureString(properties.author), + tags: ensureArray(properties.tags), + categories: ensureArray(properties.categories), + excerpt: ensureString(properties.excerpt), + featuredImage: ensureString(properties.featuredImage), + status: ensureString(properties.status), + date: ensureDate(properties.date, new Date()), + }; + return provider.publish(token, html, metadata, publishLocation, commitMessage); +}; + +const publishFile = async (fileId) => { + let counter = 0; + await loadContent(fileId); + const publishLocations = [ + ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [], + ]; + try { + // 查询是否包含git provider 包含则需要填入提交信息 + const gitLocations = publishLocations.filter(it => gitProviderIds.indexOf(it.providerId) > -1); + let commitMsg = ''; + if (gitLocations.length) { + try { + const { commitMessage } = await store.dispatch('modal/open', { type: 'commitMessage' }); + commitMsg = commitMessage; + } catch (e) { + return; + } + } + await utils.awaitSequence(publishLocations, async (publishLocation) => { + await store.dispatch('queue/doWithLocation', { + location: publishLocation, + action: async () => { + const publishLocationToStore = await publish(publishLocation, commitMsg); + try { + // Replace publish location if modified + if (utils.serializeObject(publishLocation) !== + utils.serializeObject(publishLocationToStore) + ) { + store.commit('publishLocation/patchItem', publishLocationToStore); + workspaceSvc.ensureUniqueLocations(); + } + counter += 1; + } catch (err) { + if (store.state.offline) { + throw err; + } + console.error(err); // eslint-disable-line no-console + store.dispatch('notification/error', err); + } + }, + }); + }); + const file = store.state.file.itemsById[fileId]; + store.dispatch('notification/info', `"${file.name}"已发布到${counter}个位置。`); + } finally { + await localDbSvc.unloadContents(); + } +}; + +const requestPublish = () => { + // No publish in light mode + if (store.state.light) { + return; + } + + store.dispatch('queue/enqueuePublishRequest', async () => { + let intervalId; + const attempt = async () => { + // Only start publishing when these conditions are met + if (networkSvc.isUserActive()) { + clearInterval(intervalId); + if (!hasCurrentFilePublishLocations()) { + // Cancel publish + throw new Error('Publish not possible.'); + } + await publishFile(store.getters['file/current'].id); + badgeSvc.addBadge('triggerPublish'); + } + }; + intervalId = utils.setInterval(() => attempt(), 1000); + return attempt(); + }); +}; + +const publishLocationAndStore = async (publishLocation, commitMsg) => { + const publishLocationToStore = await publish(publishLocation, commitMsg); + workspaceSvc.addPublishLocation(publishLocationToStore); + return publishLocationToStore; +}; + +const createPublishLocation = (publishLocation, featureId) => { + const currentFile = store.getters['file/current']; + publishLocation.fileId = currentFile.id; + store.dispatch( + 'queue/enqueue', + async () => { + let commitMsg = ''; + if (gitProviderIds.indexOf(publishLocation.providerId) > -1) { + try { + const { commitMessage } = await store.dispatch('modal/open', { + type: 'commitMessage', + name: currentFile.name, + }); + commitMsg = commitMessage; + } catch (e) { + return; + } + } + await publishLocationAndStore(publishLocation, commitMsg); + store.dispatch('notification/info', `添加了一个新的发布位置 "${currentFile.name}".`); + if (featureId) { + badgeSvc.addBadge(featureId); + } + }, + ); +}; + +export default { + requestPublish, + publishLocationAndStore, + createPublishLocation, +}; diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js new file mode 100644 index 0000000..8f69df7 --- /dev/null +++ b/src/services/syncSvc.js @@ -0,0 +1,1060 @@ +import md5 from 'js-md5'; +import localDbSvc from './localDbSvc'; +import store from '../store'; +import utils from './utils'; +import diffUtils from './diffUtils'; +import networkSvc from './networkSvc'; +import providerRegistry from './providers/common/providerRegistry'; +import giteeAppDataProvider from './providers/giteeAppDataProvider'; +import githubAppDataProvider from './providers/githubAppDataProvider'; +import './providers/couchdbWorkspaceProvider'; +import './providers/githubWorkspaceProvider'; +import './providers/giteeWorkspaceProvider'; +import './providers/gitlabWorkspaceProvider'; +import './providers/giteaWorkspaceProvider'; +import './providers/googleDriveWorkspaceProvider'; +import tempFileSvc from './tempFileSvc'; +import workspaceSvc from './workspaceSvc'; +import constants from '../data/constants'; +import badgeSvc from './badgeSvc'; + +const minAutoSyncEvery = 60 * 1000; // 60 sec +const inactivityThreshold = 3 * 1000; // 3 sec +const restartSyncAfter = 30 * 1000; // 30 sec +const restartContentSyncAfter = 1000; // Enough to detect an authorize pop up +const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec +const maxContentHistory = 20; + +const LAST_SEEN = 0; +const LAST_MERGED = 1; +const LAST_SENT = 2; + +let actionProvider; +let workspaceProvider; + +/** + * Use a lock in the local storage to prevent multiple windows concurrency. + */ +let lastSyncActivity; +const getLastStoredSyncActivity = () => + parseInt(localStorage.getItem(store.getters['workspace/lastSyncActivityKey']), 10) || 0; + +/** + * Return true if workspace sync is possible. + */ +const isWorkspaceSyncPossible = () => !!store.getters['workspace/syncToken']; + +/** + * Return true if file has at least one explicit sync location. + */ +const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length; + +/** + * Return true if we are online and we have something to sync. + */ +const isSyncPossible = () => !store.state.offline && + (isWorkspaceSyncPossible() || hasCurrentFileSyncLocations()); + +/** + * Return true if we are the many window, ie we have the lastSyncActivity lock. + */ +const isSyncWindow = () => { + const storedLastSyncActivity = getLastStoredSyncActivity(); + return lastSyncActivity === storedLastSyncActivity || + Date.now() > inactivityThreshold + storedLastSyncActivity; +}; + +/** + * Return true if auto sync can start, ie if lastSyncActivity is old enough. + */ +const isAutoSyncReady = () => { + let { autoSyncEvery } = store.getters['data/computedSettings']; + if (autoSyncEvery < minAutoSyncEvery) { + autoSyncEvery = minAutoSyncEvery; + } + return Date.now() > autoSyncEvery + getLastStoredSyncActivity(); +}; + +/** + * 是否已启用工作空间的自动同步 没有配置 默认是启用了的 + */ +const isEnableAutoSyncWorkspace = () => { + const workspace = store.getters['workspace/currentWorkspace']; + return workspace.autoSync === undefined || workspace.autoSync; +}; + +/** + * Update the lastSyncActivity, assuming we have the lock. + */ +const setLastSyncActivity = () => { + const currentDate = Date.now(); + lastSyncActivity = currentDate; + localStorage.setItem(store.getters['workspace/lastSyncActivityKey'], currentDate); +}; + +/** + * Upgrade hashes if syncedContent is from an old version + */ +const upgradeSyncedContent = (syncedContent) => { + if (syncedContent.v) { + return syncedContent; + } + const hashUpgrades = {}; + const historyData = {}; + const syncHistory = {}; + Object.entries(syncedContent.historyData).forEach(([hash, content]) => { + const newContent = utils.addItemHash(content); + historyData[newContent.hash] = newContent; + hashUpgrades[hash] = newContent.hash; + }); + Object.entries(syncedContent.syncHistory).forEach(([id, hashEntries]) => { + syncHistory[id] = hashEntries.map(hash => hashUpgrades[hash]); + }); + return { + ...syncedContent, + historyData, + syncHistory, + v: 1, + }; +}; + +/** + * Clean a syncedContent. + */ +const cleanSyncedContent = (syncedContent) => { + // Clean syncHistory from removed syncLocations + Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => { + if (syncLocationId !== 'main' && !store.state.syncLocation.itemsById[syncLocationId]) { + delete syncedContent.syncHistory[syncLocationId]; + } + }); + + const allSyncLocationHashSet = new Set([] + .concat(...Object.keys(syncedContent.syncHistory) + .map(id => syncedContent.syncHistory[id]))); + + // Clean historyData from unused contents + Object.keys(syncedContent.historyData) + .map(hash => parseInt(hash, 10)) + .forEach((hash) => { + if (!allSyncLocationHashSet.has(hash)) { + delete syncedContent.historyData[hash]; + } + }); +}; + +/** + * Apply changes retrieved from the workspace provider. Update sync data accordingly. + */ +const applyChanges = (changes) => { + const allItemsById = { ...store.getters.allItemsById }; + const syncDataById = { ...store.getters['data/syncDataById'] }; + const idsToKeep = {}; + let saveSyncData = false; + let getExistingItem; + if (store.getters['workspace/currentWorkspaceIsGit']) { + const itemsByGitPath = { ...store.getters.itemsByGitPath }; + getExistingItem = existingSyncData => existingSyncData && itemsByGitPath[existingSyncData.id]; + } else { + getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId]; + } + + // Process each change + changes.forEach((change) => { + const existingSyncData = syncDataById[change.syncDataId]; + const existingItem = getExistingItem(existingSyncData); + // If item was removed + if (!change.item && existingSyncData) { + if (syncDataById[change.syncDataId]) { + delete syncDataById[change.syncDataId]; + saveSyncData = true; + } + if (existingItem) { + // Remove object from the store + store.commit(`${existingItem.type}/deleteItem`, existingItem.id); + delete allItemsById[existingItem.id]; + } + // If item was modified + } else if (change.item && change.item.hash) { + idsToKeep[change.item.id] = true; + + if ((existingSyncData || {}).hash !== change.syncData.hash) { + syncDataById[change.syncDataId] = change.syncData; + saveSyncData = true; + } + if ( + // If no sync data or existing one is different + (existingSyncData || {}).hash !== change.item.hash + // And no existing item or existing item is different + && (existingItem || {}).hash !== change.item.hash + // And item is not content nor data, which will be merged later + && change.item.type !== 'content' && change.item.type !== 'data' + ) { + store.commit(`${change.item.type}/setItem`, change.item); + allItemsById[change.item.id] = change.item; + } + } + }); + + if (saveSyncData) { + store.dispatch('data/setSyncDataById', syncDataById); + + // Sanitize the workspace + workspaceSvc.sanitizeWorkspace(idsToKeep); + } +}; + +/** + * Create a sync location by uploading the current file content. + */ +const createSyncLocation = (syncLocation) => { + const currentFile = store.getters['file/current']; + const fileId = currentFile.id; + syncLocation.fileId = fileId; + // Use deepCopy to freeze the item + const content = utils.deepCopy(store.getters['content/current']); + store.dispatch( + 'queue/enqueue', + async () => { + const provider = providerRegistry.providersById[syncLocation.providerId]; + const token = provider.getToken(syncLocation); + const updatedSyncLocation = await provider.uploadContent(token, { + ...content, + history: [content.hash], + }, syncLocation); + await localDbSvc.loadSyncedContent(fileId); + const newSyncedContent = utils.deepCopy(upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`])); + const newSyncHistoryItem = []; + newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; + newSyncHistoryItem[LAST_SEEN] = content.hash; + newSyncHistoryItem[LAST_SENT] = content.hash; + newSyncedContent.historyData[content.hash] = content; + + store.commit('syncedContent/patchItem', newSyncedContent); + workspaceSvc.addSyncLocation(updatedSyncLocation); + store.dispatch('notification/info', `将新的同步位置添加到"${currentFile.name}"中。`); + }, + ); +}; + +/** + * Prevent from sending new data too long after old data has been fetched. + */ +const tooLateChecker = (timeout) => { + const tooLateAfter = Date.now() + timeout; + return (cb) => { + if (tooLateAfter < Date.now()) { + throw new Error('TOO_LATE'); + } + return cb(); + }; +}; + +/** + * Return true if file is in the temp folder or is a welcome file. + */ +const isTempFile = (fileId) => { + const contentId = `${fileId}/content`; + if (store.getters['data/syncDataByItemId'][contentId]) { + // If file has already been synced, let's not consider it a temp file + return false; + } + const file = store.state.file.itemsById[fileId]; + const content = store.state.content.itemsById[contentId]; + if (!file || !content) { + return false; + } + if (file.parentId === 'temp') { + return true; + } + const locations = [ + ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [], + ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [], + ]; + if (locations.length) { + // If file has sync/publish locations, it's not a temp file + return false; + } + // Return true if it's a welcome file that has no discussion + const { welcomeFileHashes } = store.getters['data/localSettings']; + const hash = utils.hash(content.text); + const hasDiscussions = Object.keys(content.discussions).length; + return file.name === 'Welcome file' && welcomeFileHashes[hash] && !hasDiscussions; +}; + +/** + * Patch sync data if some have changed in the result. + */ +const updateSyncData = (result) => { + [ + result.syncData, + result.contentSyncData, + result.fileSyncData, + ].forEach((syncData) => { + if (syncData) { + const oldSyncData = store.getters['data/syncDataById'][syncData.id]; + if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) { + store.dispatch('data/patchSyncDataById', { + [syncData.id]: syncData, + }); + } + } + }); + return result; +}; + +class SyncContext { + restartSkipContents = false; + attempted = {}; +} + +/** + * Sync one file with all its locations. + */ +const syncFile = async (fileId, syncContext = new SyncContext()) => { + const contentId = `${fileId}/content`; + syncContext.attempted[contentId] = true; + + await localDbSvc.loadSyncedContent(fileId); + try { + await localDbSvc.loadItem(contentId); + } catch (e) { + // Item may not exist if content has not been downloaded yet + } + + const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemsById[`${fileId}/syncedContent`]); + const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; + + try { + if (isTempFile(fileId)) { + return; + } + + const syncLocations = [ + ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [], + ]; + if (isWorkspaceSyncPossible()) { + syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId }); + } + + await utils.awaitSequence(syncLocations, async (syncLocation) => { + const provider = providerRegistry.providersById[syncLocation.providerId]; + if (!provider) { + return; + } + const token = provider.getToken(syncLocation); + if (!token) { + return; + } + + const downloadContent = async () => { + // On simple provider, call simply downloadContent + if (syncLocation.id !== 'main') { + return provider.downloadContent(token, syncLocation); + } + + // On workspace provider, call downloadWorkspaceContent + const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId]; + const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId]; + if (!oldContentSyncData || !oldFileSyncData) { + return null; + } + + const { content } = updateSyncData(await provider.downloadWorkspaceContent({ + token, + contentId, + contentSyncData: oldContentSyncData, + fileSyncData: oldFileSyncData, + })); + + // Return the downloaded content + return content; + }; + + const uploadContent = async (content, ifNotTooLate, commitMessage) => { + // On simple provider, call simply uploadContent + if (syncLocation.id !== 'main') { + return provider.uploadContent(token, content, syncLocation, ifNotTooLate); + } + + // On workspace provider, call uploadWorkspaceContent + const oldContentSyncData = store.getters['data/syncDataByItemId'][contentId]; + if (oldContentSyncData && oldContentSyncData.hash === content.hash) { + return syncLocation; + } + const oldFileSyncData = store.getters['data/syncDataByItemId'][fileId]; + + updateSyncData(await provider.uploadWorkspaceContent({ + token, + content, + commitMessage, + // Use deepCopy to freeze item + file: utils.deepCopy(store.state.file.itemsById[fileId]), + contentSyncData: oldContentSyncData, + fileSyncData: oldFileSyncData, + ifNotTooLate, + })); + + // Return syncLocation + return syncLocation; + }; + + const doSyncLocation = async () => { + const serverContent = await downloadContent(token, syncLocation); + const syncedContent = getSyncedContent(); + const syncHistoryItem = getSyncHistoryItem(syncLocation.id); + + // Merge content + let mergedContent; + const clientContent = utils.deepCopy(store.state.content.itemsById[contentId]); + if (!clientContent) { + mergedContent = utils.deepCopy(serverContent || null); + } else if (!serverContent // If sync location has not been created yet + // Or server and client contents are synced + || serverContent.hash === clientContent.hash + // Or server content has not changed or has already been merged + || syncedContent.historyData[serverContent.hash] + ) { + mergedContent = clientContent; + } else { + // Perform a merge with last merged content if any, or perform a simple fusion otherwise + let lastMergedContent = utils.someResult( + serverContent.history, + hash => syncedContent.historyData[hash], + ); + if (!lastMergedContent && syncHistoryItem) { + lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]]; + } + mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent); + if (mergedContent.mergeFlag) { + const file = store.state.file.itemsById[syncLocation.fileId]; + store.dispatch('notification/info', `${file.name} 存在冲突已自动合并,请注意合并结果!`); + } + } + if (!mergedContent) { + return; + } + + // Update or set content in store + store.commit('content/setItem', { + id: contentId, + text: utils.sanitizeText(mergedContent.text), + properties: utils.sanitizeText(mergedContent.properties), + discussions: mergedContent.discussions, + comments: mergedContent.comments, + }); + + // Retrieve content with its new hash value and freeze it + mergedContent = utils.deepCopy(store.state.content.itemsById[contentId]); + + // Make merged content history + const mergedContentHistory = serverContent ? serverContent.history.slice() : []; + let skipUpload = true; + if (mergedContentHistory[0] !== mergedContent.hash) { + // Put merged content hash at the beginning of history + mergedContentHistory.unshift(mergedContent.hash); + // Server content is either out of sync or its history is incomplete, do upload + skipUpload = false; + } + if (syncHistoryItem + && syncHistoryItem[LAST_SENT] != null + && syncHistoryItem[LAST_SENT] !== mergedContent.hash + ) { + // Clean up by removing the hash we've previously added + const idx = mergedContentHistory.lastIndexOf(syncHistoryItem[LAST_SENT]); + if (idx !== -1) { + mergedContentHistory.splice(idx, 1); + } + } + + // Update synced content + const newSyncedContent = utils.deepCopy(syncedContent); + const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || []; + newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; + if (serverContent && + (serverContent.hash === newSyncHistoryItem[LAST_SEEN] || + serverContent.history.includes(newSyncHistoryItem[LAST_SEEN])) + ) { + // That's the 2nd time we've seen this content, trust it for future merges + newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SEEN]; + } + newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_MERGED] || null; + newSyncHistoryItem[LAST_SEEN] = mergedContent.hash; + newSyncHistoryItem[LAST_SENT] = skipUpload ? null : mergedContent.hash; + newSyncedContent.historyData[mergedContent.hash] = mergedContent; + + // Clean synced content from unused revisions + cleanSyncedContent(newSyncedContent); + // Store synced content + store.commit('syncedContent/patchItem', newSyncedContent); + + if (skipUpload) { + // Server content and merged content are equal, skip content upload + return; + } + + // If content is to be created, schedule a restart to create the file as well + if (provider === workspaceProvider && + !store.getters['data/syncDataByItemId'][fileId] + ) { + syncContext.restartSkipContents = true; + } + + const currentWorkspace = store.getters['workspace/currentWorkspace']; + const isGit = !!store.getters['workspace/currentWorkspaceIsGit']; + let commitMsg = ''; + // 是git 并且未配置自动同步或启用了自动同步 并且文档类型是content + if (isGit && (currentWorkspace.autoSync !== undefined && !currentWorkspace.autoSync)) { + const file = store.state.file.itemsById[fileId]; + try { + const { commitMessage } = await store.dispatch('modal/open', { + type: 'commitMessage', + name: file.name, + }); + commitMsg = commitMessage; + } catch (e) { + return; + } + } + // Upload merged content + const item = { + ...mergedContent, + history: mergedContentHistory.slice(0, maxContentHistory), + }; + const syncLocationToStore = await uploadContent( + item, + tooLateChecker(restartContentSyncAfter), + commitMsg, + ); + + // Replace sync location if modified + if (utils.serializeObject(syncLocation) !== + utils.serializeObject(syncLocationToStore) + ) { + store.commit('syncLocation/patchItem', syncLocationToStore); + workspaceSvc.ensureUniqueLocations(); + } + }; + + await store.dispatch('queue/doWithLocation', { + location: syncLocation, + action: async () => { + try { + await doSyncLocation(); + } catch (err) { + if (store.state.offline || (err && err.message === 'TOO_LATE')) { + throw err; + } + console.error(err); // eslint-disable-line no-console + store.dispatch('notification/error', err); + } + }, + }); + }); + } catch (err) { + if (err && err.message === 'TOO_LATE') { + // Restart sync + await syncFile(fileId, syncContext); + } else { + throw err; + } + } finally { + await localDbSvc.unloadContents(); + } +}; + +/** + * Sync a data item, typically settings, templates or workspaces. + */ +const syncDataItem = async (dataId) => { + const getItem = () => store.state.data.itemsById[dataId] + || store.state.data.lsItemsById[dataId]; + + const oldItem = getItem(); + const oldSyncData = store.getters['data/syncDataById'][dataId]; + // Sync if item hash and syncData hash are out of sync + if (oldSyncData && oldItem && oldItem.hash === oldSyncData.hash) { + return; + } + + const token = workspaceProvider.getToken(); + const { item } = updateSyncData(await workspaceProvider.downloadWorkspaceData({ + token, + syncData: oldSyncData || { id: dataId }, + })); + + const serverItem = item; + const dataSyncData = store.getters['data/dataSyncDataById'][dataId]; + const clientItem = utils.deepCopy(getItem()); + let mergedItem = (() => { + if (!clientItem) { + return serverItem; + } + if (!serverItem) { + return clientItem; + } + if (!dataSyncData) { + return serverItem; + } + if (dataSyncData.hash !== serverItem.hash) { + // Server version has changed + if (dataSyncData.hash !== clientItem.hash && typeof clientItem.data === 'object') { + // Client version has changed as well, merge data objects + return { + ...clientItem, + data: diffUtils.mergeObjects(serverItem.data, clientItem.data), + }; + } + return serverItem; + } + return clientItem; + })(); + + if (!mergedItem) { + return; + } + + if (clientItem && dataId === 'workspaces') { + // Clean deleted workspaces + await Promise.all(Object.keys(clientItem.data) + .filter(id => !mergedItem.data[id]) + .map(id => workspaceSvc.removeWorkspace(id))); + } + + // Update item in store + store.commit('data/setItem', { + id: dataId, + ...mergedItem, + }); + + // Retrieve item with new `hash` and freeze it + mergedItem = utils.deepCopy(getItem()); + + // Upload merged data item if out of sync + if (!serverItem || serverItem.hash !== mergedItem.hash) { + updateSyncData(await workspaceProvider.uploadWorkspaceData({ + token, + item: mergedItem, + syncData: store.getters['data/syncDataById'][dataId], + ifNotTooLate: tooLateChecker(restartContentSyncAfter), + })); + } + + // Copy sync data into data sync data + store.dispatch('data/patchDataSyncDataById', { + [dataId]: utils.deepCopy(store.getters['data/syncDataById'][dataId]), + }); +}; + +/** + * Sync the whole workspace with the main provider and the current file explicit locations. + */ +const syncWorkspace = async (skipContents = false) => { + try { + const workspace = store.getters['workspace/currentWorkspace']; + const syncContext = new SyncContext(); + + // Store the sub in the DB since it's not safely stored in the token + const syncToken = store.getters['workspace/syncToken']; + const localSettings = store.getters['data/localSettings']; + if (!localSettings.syncSub) { + store.dispatch('data/patchLocalSettings', { + syncSub: syncToken.sub, + }); + } else if (localSettings.syncSub !== syncToken.sub) { + throw new Error('Synchronization failed due to token inconsistency.'); + } + + const changes = await workspaceProvider.getChanges(); + + // Apply changes + applyChanges(workspaceProvider.prepareChanges(changes)); + workspaceProvider.onChangesApplied(); + + // Prevent from sending items too long after changes have been retrieved + const ifNotTooLate = tooLateChecker(restartSyncAfter); + + // Find and save one item to save + await utils.awaitSome(() => ifNotTooLate(async () => { + const storeItemMap = { + ...store.state.file.itemsById, + ...store.state.folder.itemsById, + ...store.state.syncLocation.itemsById, + ...store.state.publishLocation.itemsById, + // Deal with contents and data later + }; + + const syncDataByItemId = store.getters['data/syncDataByItemId']; + const isGit = !!store.getters['workspace/currentWorkspaceIsGit']; + const [changedItem, syncDataToUpdate] = utils.someResult( + Object.entries(storeItemMap), + ([id, item]) => { + const syncData = syncDataByItemId[id]; + if ((syncData && syncData.hash === item.hash) + // Add file/folder only if parent folder has been added + || (!isGit && storeItemMap[item.parentId] && !syncDataByItemId[item.parentId]) + // Don't create folder if it's a git workspace + || (isGit && item.type === 'folder') + // Add file only if content has been added + || (item.type === 'file' && !syncDataByItemId[`${id}/content`]) + // 如果是发布位置 文件不存在了 则不需要更新 等待后续删除 + || (item.type === 'publishLocation' && (!item.fileId || !syncDataByItemId[`${item.fileId}/content`])) + ) { + return null; + } + return [item, syncData]; + }, + ) || []; + + if (!changedItem) return false; + + updateSyncData(await workspaceProvider.saveWorkspaceItem({ + // Use deepCopy to freeze objects + item: utils.deepCopy(changedItem), + syncData: utils.deepCopy(syncDataToUpdate), + ifNotTooLate, + })); + + return true; + })); + + // Find and remove one item to remove + await utils.awaitSome(() => ifNotTooLate(async () => { + let getItem; + let getFileItem; + let getOriginFileItem; + if (store.getters['workspace/currentWorkspaceIsGit']) { + const { itemsByGitPath } = store.getters; + getItem = syncData => itemsByGitPath[syncData.id]; + getOriginFileItem = syncData => itemsByGitPath[syncData.fileId]; + getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading / + } else { + const { allItemsById } = store.getters; + getItem = syncData => allItemsById[syncData.itemId]; + getOriginFileItem = syncData => allItemsById[syncData.fileId]; + getFileItem = syncData => allItemsById[syncData.itemId.split('/')[0]]; + } + + const syncDataById = store.getters['data/syncDataById']; + const syncDataToRemove = utils.deepCopy(utils.someResult( + Object.values(syncDataById), + (syncData) => { + if (getItem(syncData) + // We don't want to delete data items, especially on first sync + || syncData.type === 'data' + // Remove content only if file has been removed + || (syncData.type === 'content' && getFileItem(syncData)) + // 发布位置 如果对应的文件不存在了 也需要删除 + || (syncData.type === 'publishLocation' && syncData.fileId && getOriginFileItem(syncData)) + ) { + return null; + } + return syncData; + }, + )); + + if (!syncDataToRemove) return false; + + await workspaceProvider.removeWorkspaceItem({ + syncData: syncDataToRemove, + ifNotTooLate, + }); + const syncDataByIdCopy = { ...store.getters['data/syncDataById'] }; + delete syncDataByIdCopy[syncDataToRemove.id]; + store.dispatch('data/setSyncDataById', syncDataByIdCopy); + return true; + })); + + // Sync settings, workspaces and badges only in the main workspace + if (workspace.id === 'main') { + // await syncDataItem('settings'); + await syncDataItem('workspaces'); + await syncDataItem('badgeCreations'); + // await syncDataItem('templates'); + } + + if (!skipContents) { + const currentFileId = store.getters['file/current'].id; + if (currentFileId) { + // Sync current file first + await syncFile(currentFileId, syncContext); + } + + // Find and sync one file out of sync + await utils.awaitSome(async () => { + let getSyncData; + if (store.getters['workspace/currentWorkspaceIsGit']) { + const { gitPathsByItemId } = store.getters; + const syncDataById = store.getters['data/syncDataById']; + getSyncData = contentId => syncDataById[gitPathsByItemId[contentId]]; + } else { + const syncDataByItemId = store.getters['data/syncDataByItemId']; + getSyncData = contentId => syncDataByItemId[contentId]; + } + + // Collect all [fileId, contentId] + const ids = [ + ...Object.keys(localDbSvc.hashMap.content) + .map(contentId => [contentId.split('/')[0], contentId]), + ...store.getters['file/items'] + .map(file => [file.id, `${file.id}/content`]), + ]; + + // Find the first content out of sync + const contentMap = store.state.content.itemsById; + const fileIdToSync = utils.someResult(ids, ([fileId, contentId]) => { + // Get the content hash from itemsById or from localDbSvc if not loaded + const loadedContent = contentMap[contentId]; + const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; + const syncData = getSyncData(contentId); + if ( + // Sync if content syncing was not attempted yet + !syncContext.attempted[contentId] && + // And if syncData does not exist or if content hash and syncData hash are inconsistent + (!syncData || syncData.hash !== hash) + ) { + return fileId; + } + return null; + }); + + if (!fileIdToSync) return false; + + await syncFile(fileIdToSync, syncContext); + return true; + }); + } + + // Restart sync if requested + if (syncContext.restartSkipContents) { + await syncWorkspace(true); + } + + if (workspace.id === 'main') { + badgeSvc.addBadge(workspace.providerId === 'giteeAppData' ? 'syncMainWorkspace' : 'githubSyncMainWorkspace'); + } + } catch (err) { + if (err && err.message === 'TOO_LATE') { + // Restart sync + await syncWorkspace(); + } else { + throw err; + } + } +}; + +const syncImg = async (absolutePath) => { + const token = workspaceProvider.getToken(); + const path = absolutePath.substring(1, absolutePath.length).replaceAll('%20', ' '); + const { sha, content } = await workspaceProvider.downloadFile({ + token, + path, + }); + if (!sha || !content) { + return; + } + await localDbSvc.saveImg({ + id: md5(absolutePath), + path: absolutePath, + content, + uploaded: 1, + sha, + }); +}; + +const uploadImg = async (imgIds, index = 0) => { + if (imgIds.length - 1 < index) { + return; + } + const item = await localDbSvc.getImgItem(imgIds[index]); + // 不存在item 或已上传 则跳过 + if (!item || item.uploaded) { + setTimeout(await uploadImg(imgIds, index + 1), 10); + return; + } + const token = workspaceProvider.getToken(); + const { sha } = await workspaceProvider.uploadWorkspaceContent({ + token, + file: { + ...utils.deepCopy(item), + type: 'img', + path: item.path.substring(1, item.path.length).replaceAll('%20', ' '), + }, + isImg: true, + }); + await localDbSvc.saveImg({ + ...item, + uploaded: 1, + sha, + }); + setTimeout(await uploadImg(imgIds, index + 1), 500); +}; + +const uploadImgs = async () => { + // 新增的图片 + const imgIds = await localDbSvc.getWaitUploadImgIds(); + if (imgIds.length > 0) { + await uploadImg(imgIds); + } +}; + +/** + * Enqueue a sync task, if possible. + */ +const requestSync = (addTriggerSyncBadge = false) => { + // No sync in light mode + if (store.state.light) { + return; + } + + store.dispatch('queue/enqueueSyncRequest', async () => { + let intervalId; + const attempt = async () => { + // Only start syncing when these conditions are met + if (networkSvc.isUserActive() && isSyncWindow()) { + clearInterval(intervalId); + if (!isSyncPossible()) { + // Cancel sync + throw new Error('无法同步。'); + } + + // Determine if we have to clean files + const fileHashesToClean = {}; + if (getLastStoredSyncActivity() + constants.cleanTrashAfter < Date.now()) { + // Last synchronization happened 7 days ago + const syncDataByItemId = store.getters['data/syncDataByItemId']; + store.getters['file/items'].forEach((file) => { + // If file is in the trash and has not been modified since it was last synced + const syncData = syncDataByItemId[file.id]; + if (syncData && file.parentId === 'trash' && file.hash === syncData.hash) { + fileHashesToClean[file.id] = file.hash; + } + }); + } + + // Call setLastSyncActivity periodically + intervalId = utils.setInterval(() => setLastSyncActivity(), 1000); + setLastSyncActivity(); + + try { + if (isWorkspaceSyncPossible()) { + await syncWorkspace(); + } else if (hasCurrentFileSyncLocations()) { + // Only sync the current file if workspace sync is unavailable + // as we don't want to look for out-of-sync files by loading + // all the syncedContent objects. + await syncFile(store.getters['file/current'].id); + } + // 同步图片 + await uploadImgs(); + + // Clean files + Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => { + const file = store.state.file.itemsById[fileId]; + if (file && file.hash === fileHash) { + workspaceSvc.deleteFile(fileId); + } + }); + + if (addTriggerSyncBadge) { + badgeSvc.addBadge('triggerSync'); + } + } finally { + clearInterval(intervalId); + } + } + }; + + intervalId = utils.setInterval(() => attempt(), 1000); + return attempt(); + }); +}; + +const afterSignIn = async () => { + if (store.getters['workspace/currentWorkspace'].id === 'main' && workspaceProvider) { + const mainToken = store.getters['workspace/mainWorkspaceToken']; + // Try to find a suitable workspace sync provider + workspaceProvider = mainToken.providerId === 'githubAppData' ? githubAppDataProvider : giteeAppDataProvider; + await workspaceProvider.initWorkspace(); + } +}; + +export default { + async init() { + // Load workspaces and tokens from localStorage + localDbSvc.syncLocalStorage(); + + // Try to find a suitable action provider + actionProvider = providerRegistry.providersById[utils.queryParams.providerId]; + if (actionProvider && actionProvider.initAction) { + await actionProvider.initAction(); + } + + const mainToken = store.getters['workspace/mainWorkspaceToken']; + // Try to find a suitable workspace sync provider + workspaceProvider = providerRegistry.providersById[utils.queryParams.providerId]; + if (!workspaceProvider || !workspaceProvider.initWorkspace) { + workspaceProvider = mainToken && mainToken.providerId === 'githubAppData' ? githubAppDataProvider : giteeAppDataProvider; + } + const workspace = await workspaceProvider.initWorkspace(); + // Fix the URL hash + const { paymentSuccess } = utils.queryParams; + utils.setQueryParams(workspaceProvider.getWorkspaceParams(workspace)); + + store.dispatch('workspace/setCurrentWorkspaceId', workspace.id); + await localDbSvc.init(); + + // Enable sponsorship + if (paymentSuccess) { + store.dispatch('modal/open', 'paymentSuccess') + .catch(() => { /* Cancel */ }); + const sponsorToken = store.getters['workspace/sponsorToken']; + // Force check sponsorship after a few seconds + const currentDate = Date.now(); + if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) { + store.dispatch('data/addGoogleToken', { + ...sponsorToken, + expiresOn: currentDate - checkSponsorshipAfter, + }); + } + } + + // Try to find a suitable action provider + actionProvider = providerRegistry.providersById[utils.queryParams.providerId] || actionProvider; + if (actionProvider && actionProvider.performAction) { + const newSyncLocation = await actionProvider.performAction(); + if (newSyncLocation) { + this.createSyncLocation(newSyncLocation); + } + } + + await tempFileSvc.init(); + + if (!store.state.light) { + // Sync periodically + utils.setInterval(() => { + if (isSyncPossible() + && networkSvc.isUserActive() + && isSyncWindow() + && isAutoSyncReady() + && isEnableAutoSyncWorkspace() + ) { + requestSync(); + } + }, 1000); + + // Unload contents from memory periodically + utils.setInterval(() => { + // Wait for sync and 发布到finish + if (store.state.queue.isEmpty) { + localDbSvc.unloadContents(); + } + }, 5000); + } + }, + afterSignIn, + syncImg, + isSyncPossible, + requestSync, + createSyncLocation, +}; diff --git a/src/services/tempFileSvc.js b/src/services/tempFileSvc.js new file mode 100644 index 0000000..76563c4 --- /dev/null +++ b/src/services/tempFileSvc.js @@ -0,0 +1,99 @@ +import cledit from './editor/cledit'; +import store from '../store'; +import utils from './utils'; +import editorSvc from './editorSvc'; +import workspaceSvc from './workspaceSvc'; + +const { + origin, + fileName, + contentText, + contentProperties, +} = utils.queryParams; +const isLight = origin && window.parent; + +export default { + setReady() { + if (isLight) { + // Wait for the editor to init + setTimeout(() => window.parent.postMessage({ type: 'ready' }, origin), 1); + } + }, + closed: false, + close() { + if (isLight) { + if (!this.closed) { + window.parent.postMessage({ type: 'close' }, origin); + } + this.closed = true; + } + }, + async init() { + if (!isLight) { + return; + } + store.commit('setLight', true); + + const file = await workspaceSvc.createFile({ + name: fileName || utils.getHostname(origin), + text: contentText || '\n', + properties: contentProperties, + parentId: 'temp', + }, true); + + // Sanitize file creations + const lastCreated = {}; + const fileItemsById = store.state.file.itemsById; + Object.entries(store.getters['data/lastCreated']).forEach(([id, value]) => { + if (fileItemsById[id] && fileItemsById[id].parentId === 'temp') { + lastCreated[id] = value; + } + }); + + // Track file creation from other site + lastCreated[file.id] = { + created: Date.now(), + }; + + // Keep only the last 10 temp files created by other sites + Object.entries(lastCreated) + .sort(([, value1], [, value2]) => value2.created - value1.created) + .splice(10) + .forEach(([id]) => { + delete lastCreated[id]; + workspaceSvc.deleteFile(id); + }); + + // Store file creations and open the file + store.dispatch('data/setLastCreated', lastCreated); + store.commit('file/setCurrentId', file.id); + + const onChange = cledit.Utils.debounce(() => { + const currentFile = store.getters['file/current']; + if (currentFile.id !== file.id) { + // Close editor if file has changed for some reason + this.close(); + } else if (!this.closed && editorSvc.previewCtx.html != null) { + const content = store.getters['content/current']; + const properties = utils.computeProperties(content.properties); + window.parent.postMessage({ + type: 'fileChange', + payload: { + id: file.id, + name: currentFile.name, + content: { + text: content.text.slice(0, -1), // Remove trailing LF + properties, + yamlProperties: content.properties, + html: editorSvc.previewCtx.html, + }, + }, + }, origin); + } + }, 25); + + // Watch preview refresh and file name changes + editorSvc.$on('previewCtx', onChange); + store.watch(() => store.getters['file/current'].name, onChange); + }, +}; diff --git a/src/services/templateWorker.js b/src/services/templateWorker.js new file mode 100644 index 0000000..8cc69e6 --- /dev/null +++ b/src/services/templateWorker.js @@ -0,0 +1,99 @@ +// This WebWorker provides a safe environment to run user scripts +// See http://stackoverflow.com/questions/10653809/making-webworkers-a-safe-environment/10796616 + +import Handlebars from 'handlebars'; + +// Classeur own helpers +Handlebars.registerHelper('tocToHtml', (toc, depth = 6) => { + function arrayToHtml(arr) { + if (!arr || !arr.length || arr[0].level > depth) { + return ''; + } + const ulHtml = arr.map((item) => { + let result = '- '; + if (item.anchor && item.title) { + result += `${item.title}`; + } + result += arrayToHtml(item.children); + return `${result}
`; + }).join('\n'); + return `\n\n${ulHtml}\n
\n`; + } + return new Handlebars.SafeString(arrayToHtml(toc)); +}); + +const whiteList = { + self: 1, + onmessage: 1, + postMessage: 1, + global: 1, + whiteList: 1, + eval: 1, + Array: 1, + Boolean: 1, + Date: 1, + Function: 1, + Number: 1, + Object: 1, + RegExp: 1, + String: 1, + Error: 1, + EvalError: 1, + RangeError: 1, + ReferenceError: 1, + SyntaxError: 1, + TypeError: 1, + URIError: 1, + decodeURI: 1, + decodeURIComponent: 1, + encodeURI: 1, + encodeURIComponent: 1, + isFinite: 1, + isNaN: 1, + parseFloat: 1, + parseInt: 1, + Infinity: 1, + JSON: 1, + Math: 1, + NaN: 1, + undefined: 1, + safeEval: 1, + close: 1, +}; + +/* eslint-disable no-restricted-globals */ +let global = self; +while (global !== Object.prototype) { + Object.getOwnPropertyNames(global).forEach((prop) => { // eslint-disable-line no-loop-func + if (!Object.prototype.hasOwnProperty.call(whiteList, prop)) { + try { + Object.defineProperty(global, prop, { + get() { + throw new Error(`Security Exception: cannot access ${prop}`); + }, + configurable: false, + }); + } catch (e) { + // Ignore + } + } + }); + global = Object.getPrototypeOf(global); +} +self.Handlebars = Handlebars; + +function safeEval(code) { + eval(`"use strict";\n${code}`); // eslint-disable-line no-eval +} + +self.onmessage = (evt) => { + try { + const template = Handlebars.compile(evt.data[0]); + const context = evt.data[1]; + safeEval(evt.data[2]); + self.postMessage([null, template(context)]); + } catch (err) { + self.postMessage([`${err}`]); + } + close(); +}; diff --git a/src/services/timeSvc.js b/src/services/timeSvc.js new file mode 100644 index 0000000..65552fe --- /dev/null +++ b/src/services/timeSvc.js @@ -0,0 +1,113 @@ +// Credit: https://github.com/github/time-elements/ +const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +const pad = num => `0${num}`.slice(-2); + +function strftime(time, formatString) { + const day = time.getDay(); + const date = time.getDate(); + const month = time.getMonth(); + const year = time.getFullYear(); + const hour = time.getHours(); + const minute = time.getMinutes(); + const second = time.getSeconds(); + return formatString.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g, (_arg) => { + let match; + const modifier = _arg[1]; + switch (modifier) { + case '%': + default: + return '%'; + case 'a': + return weekdays[day].slice(0, 3); + case 'A': + return weekdays[day]; + case 'b': + return months[month].slice(0, 3); + case 'B': + return months[month]; + case 'c': + return time.toString(); + case 'd': + return pad(date); + case 'e': + return date; + case 'H': + return pad(hour); + case 'I': + return pad(strftime(time, '%l')); + case 'l': + return hour === 0 || hour === 12 ? 12 : (hour + 12) % 12; + case 'm': + return pad(month + 1); + case 'M': + return pad(minute); + case 'p': + return hour > 11 ? 'PM' : 'AM'; + case 'P': + return hour > 11 ? 'pm' : 'am'; + case 'S': + return pad(second); + case 'w': + return day; + case 'y': + return pad(year % 100); + case 'Y': + return year; + case 'Z': + match = time.toString().match(/\((\w+)\)$/); + return match ? match[1] : ''; + case 'z': + match = time.toString().match(/\w([+-]\d\d\d\d) /); + return match ? match[1] : ''; + } + }); +} + +class RelativeTime { + constructor(date) { + this.date = date; + } + + toString() { + const ago = this.timeElapsed(); + return ago || `${this.formatDate()}`; + } + + timeElapsed() { + const ms = new Date().getTime() - this.date.getTime(); + const sec = Math.round(ms / 1000); + const min = Math.round(sec / 60); + const hr = Math.round(min / 60); + const day = Math.round(hr / 24); + if (ms < 0) { + return '刚刚'; + } else if (sec < 45) { + return '刚刚'; + } else if (sec < 90) { + return '1分钟前'; + } else if (min < 45) { + return `${min}分钟前`; + } else if (min < 90) { + return '1小时前'; + } else if (hr < 24) { + return `${hr}小时前`; + } else if (hr < 36) { + return '1天前'; + } else if (day < 30) { + return `${day}天前`; + } + return null; + } + + formatDate() { + return strftime(this.date, '%Y-%m-%d'); + } +} + +export default { + format(time) { + return time && new RelativeTime(new Date(time)).toString(); + }, +}; diff --git a/src/services/userSvc.js b/src/services/userSvc.js new file mode 100644 index 0000000..b8aae11 --- /dev/null +++ b/src/services/userSvc.js @@ -0,0 +1,91 @@ +import store from '../store'; +import utils from './utils'; + +const refreshUserInfoAfter = 60 * 60 * 1000; // 60 minutes + +const infoResolversByType = {}; +const subPrefixesByType = {}; +const typesBySubPrefix = {}; + +const lastInfosByUserId = {}; +const infoPromisedByUserId = {}; + +const sanitizeUserId = (userId) => { + const prefix = userId[2] === ':' && userId.slice(0, 2); + if (typesBySubPrefix[prefix]) { + return userId; + } + return `go:${userId}`; +}; + +const parseUserId = userId => [typesBySubPrefix[userId.slice(0, 2)], userId.slice(3)]; + +const refreshUserInfos = () => { + if (store.state.offline) { + return; + } + + Object.entries(lastInfosByUserId) + .filter(([userId, lastInfo]) => lastInfo === 0 && !infoPromisedByUserId[userId]) + .forEach(async ([userId]) => { + const [type, sub] = parseUserId(userId); + const infoResolver = infoResolversByType[type]; + if (infoResolver) { + try { + infoPromisedByUserId[userId] = true; + const userInfo = await infoResolver(sub); + store.commit('userInfo/setItem', userInfo); + } finally { + infoPromisedByUserId[userId] = false; + lastInfosByUserId[userId] = Date.now(); + } + } + }); +}; + +export default { + setInfoResolver(type, subPrefix, resolver) { + infoResolversByType[type] = resolver; + subPrefixesByType[type] = subPrefix; + typesBySubPrefix[subPrefix] = type; + }, + getCurrentUserId() { + const loginToken = store.getters['workspace/loginToken']; + if (!loginToken) { + return null; + } + const loginType = store.getters['workspace/loginType']; + const prefix = subPrefixesByType[loginType]; + return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub; + }, + sanitizeUserId, + addUserInfo(userInfo) { + store.commit('userInfo/setItem', userInfo); + lastInfosByUserId[userInfo.id] = Date.now(); + }, + addUserId(userId) { + if (userId) { + const sanitizedUserId = sanitizeUserId(userId); + const lastInfo = lastInfosByUserId[sanitizedUserId]; + if (lastInfo === undefined) { + // Try to find a token with this sub to resolve name as soon as possible + const [type, sub] = parseUserId(sanitizedUserId); + const token = store.getters['data/tokensByType'][type][sub]; + if (token) { + store.commit('userInfo/setItem', { + id: sanitizedUserId, + name: token.name, + }); + } + } + + if (lastInfo === undefined || lastInfo + refreshUserInfoAfter < Date.now()) { + lastInfosByUserId[sanitizedUserId] = 0; + refreshUserInfos(); + } + } + }, +}; + +// Get user info periodically +utils.setInterval(() => refreshUserInfos(), 60 * 1000); diff --git a/src/services/utils.js b/src/services/utils.js new file mode 100644 index 0000000..d171432 --- /dev/null +++ b/src/services/utils.js @@ -0,0 +1,439 @@ +import yaml from 'js-yaml'; +import '../libs/clunderscore'; +import presets from '../data/presets'; +import constants from '../data/constants'; + +// For utils.uid() +const uidLength = 16; +const crypto = window.crypto || window.msCrypto; +const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); +const radix = alphabet.length; +const array = new Uint32Array(uidLength); + +// For utils.parseQueryParams() +const parseQueryParams = (params) => { + const result = {}; + params.split('&').forEach((param) => { + const [key, value] = param.split('=').map(decodeURIComponent); + if (key && value != null) { + result[key] = value; + } + }); + return result; +}; + +// For utils.setQueryParams() +const filterParams = (params = {}) => { + const result = {}; + Object.entries(params).forEach(([key, value]) => { + if (key && value != null) { + result[key] = value; + } + }); + return result; +}; + +// For utils.computeProperties() +const deepOverride = (obj, opt) => { + if (obj === undefined) { + return opt; + } + const objType = Object.prototype.toString.call(obj); + const optType = Object.prototype.toString.call(opt); + if (objType !== optType) { + return obj; + } + if (objType !== '[object Object]') { + return opt === undefined ? obj : opt; + } + Object.keys({ + ...obj, + ...opt, + }).forEach((key) => { + obj[key] = deepOverride(obj[key], opt[key]); + }); + return obj; +}; + +// For utils.addQueryParams() +const urlParser = document.createElement('a'); + +const deepCopy = (obj) => { + if (obj == null) { + return obj; + } + return JSON.parse(JSON.stringify(obj)); +}; + +// Compute presets +const computedPresets = {}; +Object.keys(presets).forEach((key) => { + let preset = deepCopy(presets[key][0]); + if (presets[key][1]) { + preset = deepOverride(preset, presets[key][1]); + } + computedPresets[key] = preset; +}); + +export default { + computedPresets, + queryParams: parseQueryParams(window.location.hash.slice(1)), + setQueryParams(params = {}) { + this.queryParams = filterParams(params); + const serializedParams = Object.entries(this.queryParams).map(([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); + const hash = `#${serializedParams}`; + if (window.location.hash !== hash) { + window.location.replace(hash); + } + }, + sanitizeText(text) { + const result = `${text || ''}`.slice(0, constants.textMaxLength); + // last char must be a `\n`. + return `${result}\n`.replace(/\n\n$/, '\n'); + }, + sanitizeName(name) { + return `${name || ''}` + // Keep only 250 characters + .slice(0, 250) || constants.defaultName; + }, + sanitizeFilename(name) { + return this.sanitizeName(`${name || ''}` + // Replace `/`, control characters and other kind of spaces with a space + .replace(/[/\x00-\x1F\x7f-\xa0\s]+/g, ' ') // eslint-disable-line no-control-regex + .trim()) || constants.defaultName; + }, + deepCopy, + serializeObject(obj) { + return obj === undefined ? obj : JSON.stringify(obj, (key, value) => { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return value; + } + // Sort keys to have a predictable result + return Object.keys(value).sort().reduce((sorted, valueKey) => { + sorted[valueKey] = value[valueKey]; + return sorted; + }, {}); + }); + }, + search(items, criteria) { + let result; + items.some((item) => { + // If every field fits the criteria + if (Object.entries(criteria).every(([key, value]) => value === item[key])) { + result = item; + } + return result; + }); + return result; + }, + uid() { + crypto.getRandomValues(array); + return array.cl_map(value => alphabet[value % radix]).join(''); + }, + hash(str) { + // https://stackoverflow.com/a/7616484/1333165 + let hash = 0; + if (!str) return hash; + for (let i = 0; i < str.length; i += 1) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; // eslint-disable-line no-bitwise + hash |= 0; // eslint-disable-line no-bitwise + } + return hash; + }, + getItemHash(item) { + return this.hash(this.serializeObject({ + ...item, + // These properties must not be part of the hash + id: undefined, + hash: undefined, + history: undefined, + })); + }, + addItemHash(item) { + return { + ...item, + hash: this.getItemHash(item), + }; + }, + makeWorkspaceId(params) { + return Math.abs(this.hash(this.serializeObject(params))).toString(36); + }, + getDbName(workspaceId) { + let dbName = 'stackedit-db'; + if (workspaceId !== 'main') { + dbName += `-${workspaceId}`; + } + return dbName; + }, + encodeBase64(str, urlSafe = false) { + const uriEncodedStr = encodeURIComponent(str); + const utf8Str = uriEncodedStr.replace( + /%([0-9A-F]{2})/g, + (match, p1) => String.fromCharCode(`0x${p1}`), + ); + const result = btoa(utf8Str); + if (!urlSafe) { + return result; + } + return result + .replace(/\//g, '_') // Replace `/` with `_` + .replace(/\+/g, '-') // Replace `+` with `-` + .replace(/=+$/, ''); // Remove trailing `=` + }, + encodeFiletoBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(',').pop()); + reader.onerror = error => reject(error); + }); + }, + base64ToBlob(dataurl, fileName) { + const potIdx = fileName.lastIndexOf('.'); + const suffix = potIdx > -1 ? fileName.substring(potIdx + 1) : 'png'; + const mime = `image/${suffix}`; + const bstr = atob(dataurl); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n >= 0) { + n -= 1; + u8arr[n] = bstr.charCodeAt(n); + } + return new Blob([u8arr], { type: mime }); + }, + decodeBase64(str) { + // In case of URL safe base64 + const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+'); + const utf8Str = atob(sanitizedStr); + const uriEncodedStr = utf8Str + .split('') + .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) + .join(''); + return decodeURIComponent(uriEncodedStr); + }, + computeProperties(yamlProperties) { + let properties = {}; + try { + properties = yaml.safeLoad(yamlProperties) || {}; + } catch (e) { + // Ignore + } + const extensions = properties.extensions || {}; + const computedPreset = deepCopy(computedPresets[extensions.preset] || computedPresets.default); + const computedExtensions = deepOverride(computedPreset, properties.extensions); + computedExtensions.preset = extensions.preset; + properties.extensions = computedExtensions; + return properties; + }, + randomize(value) { + return Math.floor((1 + (Math.random() * 0.2)) * value); + }, + setInterval(func, interval) { + return setInterval(() => func(), this.randomize(interval)); + }, + async awaitSequence(values, asyncFunc) { + const results = []; + const valuesLeft = values.slice().reverse(); + const runWithNextValue = async () => { + if (!valuesLeft.length) { + return results; + } + results.push(await asyncFunc(valuesLeft.pop())); + return runWithNextValue(); + }; + return runWithNextValue(); + }, + async awaitSome(asyncFunc) { + if (await asyncFunc()) { + return this.awaitSome(asyncFunc); + } + return null; + }, + someResult(values, func) { + let result; + values.some((value) => { + result = func(value); + return result; + }); + return result; + }, + parseQueryParams, + addQueryParams(url = '', params = {}, hash = false) { + const keys = Object.keys(params).filter(key => params[key] != null); + urlParser.href = url; + if (!keys.length) { + return urlParser.href; + } + const serializedParams = keys.map(key => + `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); + if (hash) { + if (urlParser.hash) { + urlParser.hash += '&'; + } else { + urlParser.hash = '#'; + } + urlParser.hash += serializedParams; + } else { + if (urlParser.search) { + urlParser.search += '&'; + } else { + urlParser.search = '?'; + } + urlParser.search += serializedParams; + } + return urlParser.href; + }, + resolveUrl(baseUrl, path) { + const oldBaseElt = document.getElementsByTagName('base')[0]; + const oldHref = oldBaseElt && oldBaseElt.href; + const newBaseElt = oldBaseElt || document.head.appendChild(document.createElement('base')); + newBaseElt.href = baseUrl; + urlParser.href = path; + const result = urlParser.href; + if (oldBaseElt) { + oldBaseElt.href = oldHref; + } else { + document.head.removeChild(newBaseElt); + } + return result; + }, + getHostname(url) { + urlParser.href = url; + return urlParser.hostname; + }, + encodeUrlPath(path) { + return path ? path.split('/').map(encodeURIComponent).join('/') : ''; + }, + decodeUrlPath(path) { + return path ? path.split('/').map(decodeURIComponent).join('/') : ''; + }, + parseGithubRepoUrl(url) { + const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/); + return parsedRepo && { + owner: parsedRepo[1], + repo: parsedRepo[2], + }; + }, + parseGitlabProjectPath(url) { + const parsedProject = url && url.match(/^http[s]?:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); + return parsedProject && parsedProject[1]; + }, + parseGiteaProjectPath(url) { + const parsedProject = url && url.match(/^http[s]?:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); + return parsedProject && parsedProject[1]; + }, + createHiddenIframe(url) { + const iframeElt = document.createElement('iframe'); + iframeElt.style.position = 'absolute'; + iframeElt.style.left = '-99px'; + iframeElt.style.width = '1px'; + iframeElt.style.height = '1px'; + iframeElt.src = url; + return iframeElt; + }, + wrapRange(range, eltProperties) { + const rangeLength = `${range}`.length; + let wrappedLength = 0; + const treeWalker = document + .createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT); + let { startOffset } = range; + treeWalker.currentNode = range.startContainer; + if (treeWalker.currentNode.nodeType === Node.TEXT_NODE || treeWalker.nextNode()) { + do { + if (treeWalker.currentNode.nodeValue !== '\n') { + if (treeWalker.currentNode === range.endContainer && + range.endOffset < treeWalker.currentNode.nodeValue.length + ) { + treeWalker.currentNode.splitText(range.endOffset); + } + if (startOffset) { + treeWalker.currentNode = treeWalker.currentNode.splitText(startOffset); + startOffset = 0; + } + const elt = document.createElement('span'); + Object.entries(eltProperties).forEach(([key, value]) => { + elt[key] = value; + }); + treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode); + elt.appendChild(treeWalker.currentNode); + } + wrappedLength += treeWalker.currentNode.nodeValue.length; + if (wrappedLength >= rangeLength) { + break; + } + } + while (treeWalker.nextNode()); + } + }, + unwrapRange(eltCollection) { + Array.prototype.slice.call(eltCollection).forEach((elt) => { + // Loop in case another wrapper has been added inside + for (let child = elt.firstChild; child; child = elt.firstChild) { + if (child.nodeType === 3) { + if (elt.previousSibling && elt.previousSibling.nodeType === 3) { + child.nodeValue = elt.previousSibling.nodeValue + child.nodeValue; + elt.parentNode.removeChild(elt.previousSibling); + } + if (!child.nextSibling && elt.nextSibling && elt.nextSibling.nodeType === 3) { + child.nodeValue += elt.nextSibling.nodeValue; + elt.parentNode.removeChild(elt.nextSibling); + } + } + elt.parentNode.insertBefore(child, elt); + } + elt.parentNode.removeChild(elt); + }); + }, + getAbsoluteDir(currDirNode) { + if (!currDirNode) { + return ''; + } + let path = currDirNode.item.name; + if (currDirNode.parentNode) { + const parentPath = this.getAbsoluteDir(currDirNode.parentNode); + if (parentPath) { + path = `${parentPath}/${path}`; + } + } + return path || ''; + }, + // 根据当前绝对路径 与 文件路径计算出文件绝对路径 + getAbsoluteFilePath(currDirNode, originFilePath) { + const filePath = originFilePath && originFilePath.replaceAll('\\', '/'); + const currAbsolutePath = this.getAbsoluteDir(currDirNode); + // "/"开头说明已经是绝对路径 + if (filePath.indexOf('/') === 0) { + return filePath.replaceAll(' ', '%20'); + } + let path = filePath; + // 相对上级路径 + if (path.indexOf('../') === 0) { + return this.getAbsoluteFilePath(currDirNode && currDirNode.parentNode, path.replace('../', '')); + } else if (path.indexOf('./') === 0) { + path = `${currAbsolutePath}/${path.replace('./', '')}`; + } else { + path = `${currAbsolutePath}/${path}`; + } + return (path.indexOf('/') === 0 ? path : `/${path}`).replaceAll(' ', '%20'); + }, + findNodeByPath(rootNode, currDirNode, filePath) { + // 先获取绝对路径 + const path = this.getAbsoluteFilePath(currDirNode, filePath).replaceAll('%20', ' '); + const pathArr = path.split('/'); + let node = rootNode; + for (let i = 0; i < pathArr.length; i += 1) { + if (i > 0) { + if (i === pathArr.length - 1) { + return node.files.find(it => `${it.item.name}.md` === pathArr[i]); + } + node = node.folders.find(it => it.item.name === pathArr[i]); + if (!node) { + return null; + } + } + } + return null; + }, +}; diff --git a/src/services/workspaceSvc.js b/src/services/workspaceSvc.js new file mode 100644 index 0000000..c950554 --- /dev/null +++ b/src/services/workspaceSvc.js @@ -0,0 +1,312 @@ +import store from '../store'; +import utils from './utils'; +import constants from '../data/constants'; +import badgeSvc from './badgeSvc'; + +const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/; + +export default { + + /** + * Create a file in the store with the specified fields. + */ + async createFile({ + name, + parentId, + text, + properties, + discussions, + comments, + } = {}, background = false) { + const id = utils.uid(); + const item = { + id, + name: utils.sanitizeFilename(name), + parentId: parentId || null, + }; + const content = { + id: `${id}/content`, + text: utils.sanitizeText(text || store.getters['data/computedSettings'].newFileContent), + properties: utils + .sanitizeText(properties || store.getters['data/computedSettings'].newFileProperties), + discussions: discussions || {}, + comments: comments || {}, + }; + const workspaceUniquePaths = store.getters['workspace/currentWorkspaceHasUniquePaths']; + + // Show warning dialogs + if (!background) { + // If name is being stripped + if (item.name !== constants.defaultName && item.name !== name) { + await store.dispatch('modal/open', { + type: 'stripName', + item, + }); + } + + // Check if there is already a file with that path + if (workspaceUniquePaths) { + const parentPath = store.getters.pathsByItemId[item.parentId] || ''; + const path = parentPath + item.name; + if (store.getters.itemsByPath[path]) { + await store.dispatch('modal/open', { + type: 'pathConflict', + item, + }); + } + } + } + + // Save file and content in the store + store.commit('content/setItem', content); + store.commit('file/setItem', item); + if (workspaceUniquePaths) { + this.makePathUnique(id); + } + + // Return the new file item + return store.state.file.itemsById[id]; + }, + + /** + * Make sanity checks and then create/update the folder/file in the store. + */ + async storeItem(item) { + const id = item.id || utils.uid(); + const sanitizedName = utils.sanitizeFilename(item.name); + + if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) { + await store.dispatch('modal/open', { + type: 'unauthorizedName', + item, + }); + throw new Error('未经授权的名称。'); + } + + // Show warning dialogs + // If name has been stripped + if (sanitizedName !== constants.defaultName && sanitizedName !== item.name) { + await store.dispatch('modal/open', { + type: 'stripName', + item, + }); + } + + // Check if there is a path conflict + if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { + const parentPath = store.getters.pathsByItemId[item.parentId] || ''; + const path = parentPath + sanitizedName; + const items = store.getters.itemsByPath[path] || []; + if (items.some(itemWithSamePath => itemWithSamePath.id !== id)) { + await store.dispatch('modal/open', { + type: 'pathConflict', + item, + }); + } + } + + return this.setOrPatchItem({ + ...item, + id, + }); + }, + + /** + * Create/update the folder/file in the store and make sure its path is unique. + */ + setOrPatchItem(patch) { + const item = { + ...store.getters.allItemsById[patch.id] || patch, + }; + if (!item.id) { + return null; + } + + if (patch.parentId !== undefined) { + item.parentId = patch.parentId || null; + } + if (patch.name) { + const sanitizedName = utils.sanitizeFilename(patch.name); + if (item.type !== 'folder' || !forbiddenFolderNameMatcher.exec(sanitizedName)) { + item.name = sanitizedName; + } + } + + // Save item in the store + store.commit(`${item.type}/setItem`, item); + + // Remove circular reference + this.removeCircularReference(item); + + // Ensure path uniqueness + if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { + this.makePathUnique(item.id); + } + + return store.getters.allItemsById[item.id]; + }, + + /** + * Delete a file in the store and all its related items. + */ + deleteFile(fileId) { + // Delete the file + store.commit('file/deleteItem', fileId); + // Delete the content + store.commit('content/deleteItem', `${fileId}/content`); + // Delete the syncedContent + store.commit('syncedContent/deleteItem', `${fileId}/syncedContent`); + // Delete the contentState + store.commit('contentState/deleteItem', `${fileId}/contentState`); + // Delete sync locations + (store.getters['syncLocation/groupedByFileId'][fileId] || []) + .forEach(item => store.commit('syncLocation/deleteItem', item.id)); + // Delete publish locations + (store.getters['publishLocation/groupedByFileId'][fileId] || []) + .forEach(item => store.commit('publishLocation/deleteItem', item.id)); + }, + + /** + * Sanitize the whole workspace. + */ + sanitizeWorkspace(idsToKeep) { + // Detect and remove circular references for all folders. + store.getters['folder/items'].forEach(folder => this.removeCircularReference(folder)); + + this.ensureUniquePaths(idsToKeep); + this.ensureUniqueLocations(idsToKeep); + }, + + /** + * Detect and remove circular reference for an item. + */ + removeCircularReference(item) { + const foldersById = store.state.folder.itemsById; + for ( + let parentFolder = foldersById[item.parentId]; + parentFolder; + parentFolder = foldersById[parentFolder.parentId] + ) { + if (parentFolder.id === item.id) { + store.commit('folder/patchItem', { + id: item.id, + parentId: null, + }); + break; + } + } + }, + + /** + * Ensure two files/folders don't have the same path if the workspace doesn't allow it. + */ + ensureUniquePaths(idsToKeep = {}) { + if (store.getters['workspace/currentWorkspaceHasUniquePaths']) { + if (Object.keys(store.getters.pathsByItemId) + .some(id => !idsToKeep[id] && this.makePathUnique(id)) + ) { + // Just changed one item path, restart + this.ensureUniquePaths(idsToKeep); + } + } + }, + + /** + * Return false if the file/folder path is unique. + * Add a prefix to its name and return true otherwise. + */ + makePathUnique(id) { + const { itemsByPath, allItemsById, pathsByItemId } = store.getters; + const item = allItemsById[id]; + if (!item) { + return false; + } + let path = pathsByItemId[id]; + if (itemsByPath[path].length === 1) { + return false; + } + const isFolder = item.type === 'folder'; + if (isFolder) { + // Remove trailing slash + path = path.slice(0, -1); + } + for (let suffix = 1; ; suffix += 1) { + let pathWithSuffix = `${path}.${suffix}`; + if (isFolder) { + pathWithSuffix += '/'; + } + if (!itemsByPath[pathWithSuffix]) { + store.commit(`${item.type}/patchItem`, { + id: item.id, + name: `${item.name}.${suffix}`, + }); + return true; + } + } + }, + + addSyncLocation(location) { + store.commit('syncLocation/setItem', { + ...location, + id: utils.uid(), + }); + + // Sanitize the workspace + this.ensureUniqueLocations(); + + if (Object.keys(store.getters['syncLocation/currentWithWorkspaceSyncLocation']).length > 1) { + badgeSvc.addBadge('syncMultipleLocations'); + } + }, + + addPublishLocation(location) { + store.commit('publishLocation/setItem', { + ...location, + id: utils.uid(), + }); + + // Sanitize the workspace + this.ensureUniqueLocations(); + + if (Object.keys(store.getters['publishLocation/current']).length > 1) { + badgeSvc.addBadge('publishMultipleLocations'); + } + }, + + /** + * Ensure two sync/publish locations of the same file don't have the same hash. + */ + ensureUniqueLocations(idsToKeep = {}) { + ['syncLocation', 'publishLocation'].forEach((type) => { + store.getters[`${type}/items`].forEach((item) => { + if (!idsToKeep[item.id] + && store.getters[`${type}/groupedByFileIdAndHash`][item.fileId][item.hash].length > 1 + ) { + store.commit(`${item.type}/deleteItem`, item.id); + } + }); + }); + }, + + /** + * Drop the database and clean the localStorage for the specified workspaceId. + */ + async removeWorkspace(id) { + // Remove from the store first as workspace tabs will reload. + // Workspace deletion will be persisted as soon as possible + // by the store.getters['data/workspaces'] watcher in localDbSvc. + store.dispatch('workspace/removeWorkspace', id); + + // Drop the database + await new Promise((resolve) => { + const dbName = utils.getDbName(id); + const request = indexedDB.deleteDatabase(dbName); + request.onerror = resolve; // Ignore errors + request.onsuccess = resolve; + }); + + // Clean the local storage + localStorage.removeItem(`${id}/lastSyncActivity`); + localStorage.removeItem(`${id}/lastWindowFocus`); + }, +}; diff --git a/src/store/content.js b/src/store/content.js new file mode 100644 index 0000000..2e82d1f --- /dev/null +++ b/src/store/content.js @@ -0,0 +1,114 @@ +import DiffMatchPatch from 'diff-match-patch'; +import moduleTemplate from './moduleTemplate'; +import empty from '../data/empties/emptyContent'; +import utils from '../services/utils'; +import cledit from '../services/editor/cledit'; +import badgeSvc from '../services/badgeSvc'; + +const diffMatchPatch = new DiffMatchPatch(); + +const module = moduleTemplate(empty); + +module.state = { + ...module.state, + revisionContent: null, +}; + +module.mutations = { + ...module.mutations, + setRevisionContent: (state, value) => { + if (value) { + state.revisionContent = { + ...empty(), + ...value, + id: utils.uid(), + hash: Date.now(), + }; + } else { + state.revisionContent = null; + } + }, +}; + +module.getters = { + ...module.getters, + current: ({ itemsById, revisionContent }, getters, rootState, rootGetters) => { + if (revisionContent) { + return revisionContent; + } + return itemsById[`${rootGetters['file/current'].id}/content`] || empty(); + }, + currentChangeTrigger: (state, getters) => { + const { current } = getters; + return utils.serializeObject([ + current.id, + current.text, + current.hash, + ]); + }, + currentProperties: (state, { current }) => utils.computeProperties(current.properties), + isCurrentEditable: ({ revisionContent }, { current }, rootState, rootGetters) => + !revisionContent && current.id && rootGetters['layout/styles'].showEditor, +}; + +module.actions = { + ...module.actions, + patchCurrent({ state, getters, commit }, value) { + const { id } = getters.current; + if (id && !state.revisionContent) { + commit('patchItem', { + ...value, + id, + }); + } + }, + setRevisionContent({ state, rootGetters, commit }, value) { + const currentFile = rootGetters['file/current']; + const currentContent = state.itemsById[`${currentFile.id}/content`]; + if (currentContent) { + const diffs = diffMatchPatch.diff_main(currentContent.text, value.text); + diffMatchPatch.diff_cleanupSemantic(diffs); + commit('setRevisionContent', { + text: diffs.map(([, text]) => text).join(''), + diffs, + originalText: value.text, + }); + } + }, + async restoreRevision({ + state, + getters, + commit, + dispatch, + }) { + const { revisionContent } = state; + if (revisionContent) { + await dispatch('modal/open', 'fileRestoration', { root: true }); + // Close revision + commit('setRevisionContent'); + const currentContent = utils.deepCopy(getters.current); + if (currentContent) { + // Restore text and move discussions + const diffs = diffMatchPatch + .diff_main(currentContent.text, revisionContent.originalText); + diffMatchPatch.diff_cleanupSemantic(diffs); + Object.entries(currentContent.discussions).forEach(([, discussion]) => { + const adjustOffset = (offsetName) => { + const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); + marker.adjustOffset(diffs); + discussion[offsetName] = marker.offset; + }; + adjustOffset('start'); + adjustOffset('end'); + }); + dispatch('patchCurrent', { + ...currentContent, + text: revisionContent.originalText, + }); + badgeSvc.addBadge('restoreVersion'); + } + } + }, +}; + +export default module; diff --git a/src/store/contentState.js b/src/store/contentState.js new file mode 100644 index 0000000..3cbcb1b --- /dev/null +++ b/src/store/contentState.js @@ -0,0 +1,22 @@ +import moduleTemplate from './moduleTemplate'; +import empty from '../data/empties/emptyContentState'; + +const module = moduleTemplate(empty, true); + +module.getters = { + ...module.getters, + current: ({ itemsById }, getters, rootState, rootGetters) => + itemsById[`${rootGetters['file/current'].id}/contentState`] || empty(), +}; + +module.actions = { + ...module.actions, + patchCurrent({ getters, commit }, value) { + commit('patchItem', { + ...value, + id: getters.current.id, + }); + }, +}; + +export default module; diff --git a/src/store/contextMenu.js b/src/store/contextMenu.js new file mode 100644 index 0000000..6b4180e --- /dev/null +++ b/src/store/contextMenu.js @@ -0,0 +1,55 @@ +const setter = propertyName => (state, value) => { + state[propertyName] = value; +}; + +export default { + namespaced: true, + state: { + coordinates: { + left: 0, + top: 0, + }, + items: [], + resolve: () => {}, + }, + mutations: { + setCoordinates: setter('coordinates'), + setItems: setter('items'), + setResolve: setter('resolve'), + }, + actions: { + open({ commit, rootState }, { coordinates, items }) { + commit('setItems', items); + // Place the context menu outside the screen + commit('setCoordinates', { top: 0, left: -9999 }); + // Let the UI refresh itself + setTimeout(() => { + // Take the size of the context menu and place it + const elt = document.querySelector('.context-menu__inner'); + if (elt) { + const height = elt.offsetHeight; + if (coordinates.top + height > rootState.layout.bodyHeight) { + coordinates.top -= height; + } + if (coordinates.top < 0) { + coordinates.top = 0; + } + const width = elt.offsetWidth; + if (coordinates.left + width > rootState.layout.bodyWidth) { + coordinates.left -= width; + } + if (coordinates.left < 0) { + coordinates.left = 0; + } + commit('setCoordinates', coordinates); + } + }, 1); + + return new Promise(resolve => commit('setResolve', resolve)); + }, + close({ commit }) { + commit('setItems', []); + commit('setResolve', () => {}); + }, + }, +}; diff --git a/src/store/data.js b/src/store/data.js new file mode 100644 index 0000000..374e987 --- /dev/null +++ b/src/store/data.js @@ -0,0 +1,338 @@ +import Vue from 'vue'; +import yaml from 'js-yaml'; +import utils from '../services/utils'; +import defaultWorkspaces from '../data/defaults/defaultWorkspaces'; +import defaultSettings from '../data/defaults/defaultSettings.yml'; +import defaultLocalSettings from '../data/defaults/defaultLocalSettings'; +import defaultLayoutSettings from '../data/defaults/defaultLayoutSettings'; +import plainHtmlTemplate from '../data/templates/plainHtmlTemplate.html'; +import styledHtmlTemplate from '../data/templates/styledHtmlTemplate.html'; +import styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTemplate.html'; +import styledHtmlWithThemeTemplate from '../data/templates/styledHtmlWithThemeTemplate.html'; +import styledHtmlWithThemeAndTocTemplate from '../data/templates/styledHtmlWithThemeAndTocTemplate.html'; +import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html'; +import constants from '../data/constants'; +import features from '../data/features'; +import badgeSvc from '../services/badgeSvc'; + +const itemTemplate = (id, data = {}) => ({ + id, + type: 'data', + data, + hash: 0, +}); + +const empty = (id) => { + switch (id) { + case 'workspaces': + return itemTemplate(id, defaultWorkspaces()); + case 'settings': + return itemTemplate(id, '\n'); + case 'localSettings': + return itemTemplate(id, defaultLocalSettings()); + case 'layoutSettings': + return itemTemplate(id, defaultLayoutSettings()); + default: + return itemTemplate(id); + } +}; + +// Item IDs that will be stored in the localStorage +const localStorageIdSet = new Set(constants.localStorageDataIds); + +// Getter/setter/patcher factories +const getter = id => (state) => { + const itemsById = localStorageIdSet.has(id) + ? state.lsItemsById + : state.itemsById; + if (itemsById[id]) { + return itemsById[id].data; + } + return empty(id).data; +}; +const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); +const patcher = id => ({ state, commit }, data) => { + const itemsById = localStorageIdSet.has(id) + ? state.lsItemsById + : state.itemsById; + const item = Object.assign(empty(id), itemsById[id]); + commit('setItem', { + ...empty(id), + data: typeof data === 'object' ? { + ...item.data, + ...data, + } : data, + }); +}; + +// For layoutSettings +const toggleLayoutSetting = (name, value, featureId, getters, dispatch) => { + const currentValue = getters.layoutSettings[name]; + const patch = { + [name]: value === undefined ? !currentValue : !!value, + }; + if (patch[name] !== currentValue) { + dispatch('patchLayoutSettings', patch); + badgeSvc.addBadge(featureId); + } +}; + +const layoutSettingsToggler = (propertyName, featureId) => ({ getters, dispatch }, value) => + toggleLayoutSetting(propertyName, value, featureId, getters, dispatch); + +const notEnoughSpace = (layoutConstants, showGutter) => + document.body.clientWidth < layoutConstants.editorMinWidth + + layoutConstants.explorerWidth + + layoutConstants.sideBarWidth + + layoutConstants.buttonBarWidth + + (showGutter ? layoutConstants.gutterWidth : 0); + +// For templates +const makeAdditionalTemplate = (name, value, helpers = '\n') => ({ + name, + value, + helpers, + isAdditional: true, +}); +const defaultTemplates = { + plainText: makeAdditionalTemplate('Markdown文本', '{{{files.0.content.text}}}'), + plainHtml: makeAdditionalTemplate('无样式HTML', plainHtmlTemplate), + styledHtml: makeAdditionalTemplate('标准样式HTML', styledHtmlTemplate), + styledHtmlWithToc: makeAdditionalTemplate('带目录标准样式HTML', styledHtmlWithTocTemplate), + styledHtmlWithTheme: makeAdditionalTemplate('带预览主题HTML', styledHtmlWithThemeTemplate), + styledHtmlWithThemeAndToc: makeAdditionalTemplate('带目录预览主题HTML', styledHtmlWithThemeAndTocTemplate), + jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), +}; + +// For tokens +const tokenAdder = providerId => ({ getters, dispatch }, token) => { + dispatch('patchTokensByType', { + [providerId]: { + ...getters[`${providerId}TokensBySub`], + [token.sub]: token, + }, + }); +}; + +export default { + namespaced: true, + state: { + // Data items stored in the DB + itemsById: {}, + // Data items stored in the localStorage + lsItemsById: {}, + }, + mutations: { + setItem: ({ itemsById, lsItemsById }, value) => { + // Create an empty item and override its data field + const emptyItem = empty(value.id); + const data = typeof value.data === 'object' + ? Object.assign(emptyItem.data, value.data) + : value.data; + + // Make item with hash + const item = utils.addItemHash({ + ...emptyItem, + data, + }); + + // Store item in itemsById or lsItemsById if its stored in the localStorage + Vue.set(localStorageIdSet.has(item.id) ? lsItemsById : itemsById, item.id, item); + }, + deleteItem({ itemsById }, id) { + // Only used by localDbSvc to clean itemsById from object moved to localStorage + Vue.delete(itemsById, id); + }, + }, + getters: { + serverConf: getter('serverConf'), + workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById + settings: getter('settings'), + computedSettings: (state, { settings }) => { + const customSettings = yaml.safeLoad(settings); + const parsedSettings = yaml.safeLoad(defaultSettings); + const override = (obj, opt) => { + const objType = Object.prototype.toString.call(obj); + const optType = Object.prototype.toString.call(opt); + if (objType !== optType) { + return obj; + } else if (objType !== '[object Object]') { + return opt; + } + Object.keys(obj).forEach((key) => { + if (key === 'shortcuts') { + obj[key] = Object.assign(obj[key], opt[key]); + } else { + obj[key] = override(obj[key], opt[key]); + } + }); + return obj; + }; + return override(parsedSettings, customSettings); + }, + localSettings: getter('localSettings'), + layoutSettings: getter('layoutSettings'), + templatesById: getter('templates'), + allTemplatesById: (state, { templatesById }) => ({ + ...templatesById, + ...defaultTemplates, + }), + lastCreated: getter('lastCreated'), + lastOpened: getter('lastOpened'), + lastOpenedIds: (state, { lastOpened }, rootState) => { + const result = { + ...lastOpened, + }; + const currentFileId = rootState.file.currentId; + if (currentFileId && !result[currentFileId]) { + result[currentFileId] = Date.now(); + } + return Object.keys(result) + .filter(id => rootState.file.itemsById[id]) + .sort((id1, id2) => result[id2] - result[id1]) + .slice(0, 20); + }, + syncDataById: getter('syncData'), + syncDataByItemId: (state, { syncDataById }, rootState, rootGetters) => { + const result = {}; + if (rootGetters['workspace/currentWorkspaceIsGit']) { + Object.entries(rootGetters.gitPathsByItemId).forEach(([id, path]) => { + const syncDataEntry = syncDataById[path]; + if (syncDataEntry) { + result[id] = syncDataEntry; + } + }); + } else { + Object.entries(syncDataById).forEach(([, syncDataEntry]) => { + result[syncDataEntry.itemId] = syncDataEntry; + }); + } + return result; + }, + dataSyncDataById: getter('dataSyncData'), + tokensByType: getter('tokens'), + googleTokensBySub: (state, { tokensByType }) => tokensByType.google || {}, + couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {}, + dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {}, + githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {}, + giteeTokensBySub: (state, { tokensByType }) => tokensByType.gitee || {}, + gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, + giteaTokensBySub: (state, { tokensByType }) => tokensByType.gitea || {}, + wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, + zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, + smmsTokensBySub: (state, { tokensByType }) => tokensByType.smms || {}, + customTokensBySub: (state, { tokensByType }) => tokensByType.custom || {}, + badgeCreations: getter('badgeCreations'), + badgeTree: (state, { badgeCreations }) => features + .map(feature => feature.toBadge(badgeCreations)), + allBadges: (state, { badgeTree }) => { + const result = []; + const processBadgeNodes = nodes => nodes.forEach((node) => { + result.push(node); + if (node.children) { + processBadgeNodes(node.children); + } + }); + processBadgeNodes(badgeTree); + return result; + }, + }, + actions: { + setServerConf: setter('serverConf'), + setSettings: setter('settings'), + switchThemeSetting: ({ commit, getters }) => { + const customSettingStr = getters.settings; + let { colorTheme } = getters.computedSettings; + if (!colorTheme || colorTheme === 'light') { + colorTheme = 'dark'; + } else { + colorTheme = 'light'; + } + const themeStr = `colorTheme: ${colorTheme}`; + let settingsStr = (customSettingStr && customSettingStr.trim()) || '# 增加您的自定义配置覆盖默认配置'; + settingsStr = settingsStr.indexOf('colorTheme:') > -1 ? + settingsStr.replace(/.*colorTheme:.*/, themeStr) : `${settingsStr}\n${themeStr}`; + commit('setItem', itemTemplate('settings', settingsStr)); + badgeSvc.addBadge('switchTheme'); + }, + patchLocalSettings: patcher('localSettings'), + patchLayoutSettings: patcher('layoutSettings'), + toggleNavigationBar: layoutSettingsToggler('showNavigationBar', 'toggleNavigationBar'), + toggleEditor: layoutSettingsToggler('showEditor', 'toggleEditor'), + toggleSidePreview: layoutSettingsToggler('showSidePreview', 'toggleSidePreview'), + toggleStatusBar: layoutSettingsToggler('showStatusBar', 'toggleStatusBar'), + toggleScrollSync: layoutSettingsToggler('scrollSync', 'toggleScrollSync'), + toggleFocusMode: layoutSettingsToggler('focusMode', 'toggleFocusMode'), + toggleSideBar: ({ getters, dispatch, rootGetters }, value) => { + // Reset side bar + dispatch('setSideBarPanel'); + + // Toggle it + toggleLayoutSetting('showSideBar', value, 'toggleSideBar', getters, dispatch); + + // Close explorer if not enough space + if (getters.layoutSettings.showSideBar && + notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion']) + ) { + dispatch('patchLayoutSettings', { + showExplorer: false, + }); + } + }, + toggleExplorer: ({ getters, dispatch, rootGetters }, value) => { + // Toggle explorer + toggleLayoutSetting('showExplorer', value, 'toggleExplorer', getters, dispatch); + + // Close side bar if not enough space + if (getters.layoutSettings.showExplorer && + notEnoughSpace(rootGetters['layout/constants'], rootGetters['discussion/currentDiscussion']) + ) { + dispatch('patchLayoutSettings', { + showSideBar: false, + }); + } + }, + setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', { + sideBarPanel: value === undefined ? 'menu' : value, + }), + setTemplatesById: ({ commit }, templatesById) => { + const templatesToCommit = { + ...templatesById, + }; + // We don't store additional templates + Object.keys(defaultTemplates).forEach((id) => { + delete templatesToCommit[id]; + }); + commit('setItem', itemTemplate('templates', templatesToCommit)); + }, + setLastCreated: setter('lastCreated'), + setLastOpenedId: ({ getters, commit, rootState }, fileId) => { + const lastOpened = { ...getters.lastOpened }; + lastOpened[fileId] = Date.now(); + // Remove entries that don't exist anymore + const cleanedLastOpened = {}; + Object.entries(lastOpened).forEach(([id, value]) => { + if (rootState.file.itemsById[id]) { + cleanedLastOpened[id] = value; + } + }); + commit('setItem', itemTemplate('lastOpened', cleanedLastOpened)); + }, + setSyncDataById: setter('syncData'), + patchSyncDataById: patcher('syncData'), + patchDataSyncDataById: patcher('dataSyncData'), + patchTokensByType: patcher('tokens'), + addGoogleToken: tokenAdder('google'), + addCouchdbToken: tokenAdder('couchdb'), + addDropboxToken: tokenAdder('dropbox'), + addGithubToken: tokenAdder('github'), + addGiteeToken: tokenAdder('gitee'), + addGitlabToken: tokenAdder('gitlab'), + addGiteaToken: tokenAdder('gitea'), + addWordpressToken: tokenAdder('wordpress'), + addZendeskToken: tokenAdder('zendesk'), + patchBadgeCreations: patcher('badgeCreations'), + addSmmsToken: tokenAdder('smms'), + addCustomToken: tokenAdder('custom'), + }, +}; diff --git a/src/store/discussion.js b/src/store/discussion.js new file mode 100644 index 0000000..ebd33ab --- /dev/null +++ b/src/store/discussion.js @@ -0,0 +1,187 @@ +import utils from '../services/utils'; +import giteeHelper from '../services/providers/helpers/giteeHelper'; +import githubHelper from '../services/providers/helpers/githubHelper'; +import syncSvc from '../services/syncSvc'; + +const idShifter = offset => (state, getters) => { + const ids = Object.keys(getters.currentFileDiscussions) + .filter(id => id !== state.newDiscussionId); + const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length; + return ids[idx % ids.length]; +}; + +export default { + namespaced: true, + state: { + currentDiscussionId: null, + newDiscussion: null, + newDiscussionId: null, + isCommenting: false, + newCommentText: '', + newCommentSelection: { start: 0, end: 0 }, + newCommentFocus: false, + stickyComment: null, + }, + mutations: { + setCurrentDiscussionId: (state, value) => { + if (state.currentDiscussionId !== value) { + state.currentDiscussionId = value; + state.isCommenting = false; + } + }, + setNewDiscussion: (state, value) => { + state.newDiscussion = value; + state.newDiscussionId = utils.uid(); + state.currentDiscussionId = state.newDiscussionId; + state.isCommenting = true; + state.newCommentFocus = true; + }, + patchNewDiscussion: (state, value) => { + Object.assign(state.newDiscussion, value); + }, + setIsCommenting: (state, value) => { + state.isCommenting = value; + if (!value) { + state.newDiscussionId = null; + } else { + state.newCommentFocus = true; + } + }, + setNewCommentText: (state, value) => { + state.newCommentText = value || ''; + }, + setNewCommentSelection: (state, value) => { + state.newCommentSelection = value; + }, + setNewCommentFocus: (state, value) => { + state.newCommentFocus = value; + }, + setStickyComment: (state, value) => { + state.stickyComment = value; + }, + }, + getters: { + newDiscussion: ({ currentDiscussionId, newDiscussionId, newDiscussion }) => + currentDiscussionId === newDiscussionId && newDiscussion, + currentFileDiscussionLastComments: (state, getters, rootState, rootGetters) => { + const { discussions, comments } = rootGetters['content/current']; + const discussionLastComments = {}; + Object.entries(comments).forEach(([, comment]) => { + if (discussions[comment.discussionId]) { + const lastComment = discussionLastComments[comment.discussionId]; + if (!lastComment || lastComment.created < comment.created) { + discussionLastComments[comment.discussionId] = comment; + } + } + }); + return discussionLastComments; + }, + currentFileDiscussions: ( + { newDiscussionId }, + { newDiscussion, currentFileDiscussionLastComments }, + rootState, + rootGetters, + ) => { + const currentFileDiscussions = {}; + if (newDiscussion) { + currentFileDiscussions[newDiscussionId] = newDiscussion; + } + const { discussions } = rootGetters['content/current']; + Object.entries(currentFileDiscussionLastComments) + .sort(([, lastComment1], [, lastComment2]) => + lastComment1.created - lastComment2.created) + .forEach(([discussionId]) => { + currentFileDiscussions[discussionId] = discussions[discussionId]; + }); + return currentFileDiscussions; + }, + currentDiscussion: ({ currentDiscussionId }, { currentFileDiscussions }) => + currentFileDiscussions[currentDiscussionId], + previousDiscussionId: idShifter(-1), + nextDiscussionId: idShifter(1), + currentDiscussionComments: ( + { currentDiscussionId }, + { currentDiscussion }, + rootState, + rootGetters, + ) => { + const comments = {}; + if (currentDiscussion) { + const contentComments = rootGetters['content/current'].comments; + Object.entries(contentComments) + .filter(([, comment]) => + comment.discussionId === currentDiscussionId) + .sort(([, comment1], [, comment2]) => + comment1.created - comment2.created) + .forEach(([commentId, comment]) => { + comments[commentId] = comment; + }); + } + return comments; + }, + currentDiscussionLastCommentId: (state, { currentDiscussionComments }) => + Object.keys(currentDiscussionComments).pop(), + currentDiscussionLastComment: ( + state, + { currentDiscussionComments, currentDiscussionLastCommentId }, + ) => currentDiscussionComments[currentDiscussionLastCommentId], + }, + actions: { + cancelNewComment({ commit, getters }) { + commit('setIsCommenting', false); + if (!getters.currentDiscussion) { + commit('setCurrentDiscussionId', getters.nextDiscussionId); + } + }, + async createNewDiscussion({ commit, dispatch, rootGetters }, selection) { + const loginToken = rootGetters['workspace/loginToken']; + if (!loginToken) { + try { + const signInWhere = await dispatch('modal/open', 'signInForComment', { root: true }); + if (signInWhere === 'github') { + await githubHelper.signin(); + } else { + await giteeHelper.signin(); + } + await syncSvc.afterSignIn(); + syncSvc.requestSync(); + await dispatch('createNewDiscussion', selection); + } catch (e) { /* cancel */ } + } else if (selection) { + let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); + const maxLength = 80; + if (text.length > maxLength) { + text = `${text.slice(0, maxLength - 1).trim()}…`; + } + commit('setNewDiscussion', { ...selection, text }); + } + }, + cleanCurrentFile({ + getters, + rootGetters, + commit, + dispatch, + }, { filterComment, filterDiscussion } = {}) { + const { discussions } = rootGetters['content/current']; + const { comments } = rootGetters['content/current']; + const patch = { + discussions: {}, + comments: {}, + }; + Object.entries(comments).forEach(([commentId, comment]) => { + const discussion = discussions[comment.discussionId]; + if (discussion && comment !== filterComment && discussion !== filterDiscussion) { + patch.discussions[comment.discussionId] = discussion; + patch.comments[commentId] = comment; + } + }); + + const { nextDiscussionId } = getters; + dispatch('content/patchCurrent', patch, { root: true }); + if (!getters.currentDiscussion) { + // Keep the gutter open + commit('setCurrentDiscussionId', nextDiscussionId); + } + }, + }, +}; diff --git a/src/store/explorer.js b/src/store/explorer.js new file mode 100644 index 0000000..9846981 --- /dev/null +++ b/src/store/explorer.js @@ -0,0 +1,217 @@ +import Vue from 'vue'; +import emptyFile from '../data/empties/emptyFile'; +import emptyFolder from '../data/empties/emptyFolder'; + +const setter = propertyName => (state, value) => { + state[propertyName] = value; +}; + +function debounceAction(action, wait) { + let timeoutId; + return (context) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => action(context), wait); + }; +} + +const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); +const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name); + +class Node { + constructor(item, locations = [], isFolder = false) { + this.item = item; + this.locations = locations; + this.isFolder = isFolder; + if (isFolder) { + this.folders = []; + this.files = []; + } + } + + sortChildren() { + if (this.isFolder) { + this.folders.sort(compare); + this.files.sort(compare); + this.folders.forEach(child => child.sortChildren()); + } + } +} + +const nilFileNode = new Node(emptyFile()); +nilFileNode.isNil = true; +const fakeFileNode = new Node(emptyFile()); +fakeFileNode.item.id = 'fake'; +fakeFileNode.noDrag = true; + +function getParent({ item, isNil }, { nodeMap, rootNode }) { + if (isNil) { + return nilFileNode; + } + return nodeMap[item.parentId] || rootNode; +} + +function getFolder(node, getters) { + return node.item.type === 'folder' ? + node : + getParent(node, getters); +} + +export default { + namespaced: true, + state: { + selectedId: null, + editingId: null, + dragSourceId: null, + dragTargetId: null, + newChildNode: nilFileNode, + openNodes: {}, + }, + mutations: { + setSelectedId: setter('selectedId'), + setEditingId: setter('editingId'), + setDragSourceId: setter('dragSourceId'), + setDragTargetId: setter('dragTargetId'), + setNewItem(state, item) { + state.newChildNode = item ? new Node(item, [], item.type === 'folder') : nilFileNode; + }, + setNewItemName(state, name) { + state.newChildNode.item.name = name; + }, + toggleOpenNode(state, id) { + Vue.set(state.openNodes, id, !state.openNodes[id]); + }, + }, + getters: { + nodeStructure: (state, getters, rootState, rootGetters) => { + const rootNode = new Node(emptyFolder(), [], true); + rootNode.isRoot = true; + + // Create Trash node + const trashFolderNode = new Node(emptyFolder(), [], true); + trashFolderNode.item.id = 'trash'; + trashFolderNode.item.name = '回收站'; + trashFolderNode.noDrag = true; + trashFolderNode.isTrash = true; + trashFolderNode.parentNode = rootNode; + + // Create Temp node + const tempFolderNode = new Node(emptyFolder(), [], true); + tempFolderNode.item.id = 'temp'; + tempFolderNode.item.name = '临时目录'; + tempFolderNode.noDrag = true; + tempFolderNode.noDrop = true; + tempFolderNode.isTemp = true; + tempFolderNode.parentNode = rootNode; + + // Fill nodeMap with all file and folder nodes + const nodeMap = { + trash: trashFolderNode, + temp: tempFolderNode, + }; + rootGetters['folder/items'].forEach((item) => { + nodeMap[item.id] = new Node(item, [], true); + }); + const syncLocationsByFileId = rootGetters['syncLocation/filteredGroupedByFileId']; + const publishLocationsByFileId = rootGetters['publishLocation/filteredGroupedByFileId']; + rootGetters['file/items'].forEach((item) => { + const locations = [ + ...syncLocationsByFileId[item.id] || [], + ...publishLocationsByFileId[item.id] || [], + ]; + nodeMap[item.id] = new Node(item, locations); + }); + + // Build the tree + Object.entries(nodeMap).forEach(([, node]) => { + let parentNode = nodeMap[node.item.parentId]; + if (!parentNode || !parentNode.isFolder) { + if (node.isTrash || node.isTemp) { + return; + } + parentNode = rootNode; + } + if (node.isFolder) { + parentNode.folders.push(node); + } else { + parentNode.files.push(node); + } + node.parentNode = parentNode; + }); + rootNode.sortChildren(); + + // Add Trash and Temp nodes + rootNode.folders.unshift(tempFolderNode); + tempFolderNode.files.forEach((node) => { + node.noDrop = true; + }); + rootNode.folders.unshift(trashFolderNode); + + // Add a fake file at the end of the root folder to allow drag and drop into it + rootNode.files.push(fakeFileNode); + return { + nodeMap, + rootNode, + }; + }, + nodeMap: (state, { nodeStructure }) => nodeStructure.nodeMap, + rootNode: (state, { nodeStructure }) => nodeStructure.rootNode, + newChildNodeParent: (state, getters) => getParent(state.newChildNode, getters), + selectedNode: ({ selectedId }, { nodeMap }) => nodeMap[selectedId] || nilFileNode, + selectedNodeFolder: (state, getters) => getFolder(getters.selectedNode, getters), + editingNode: ({ editingId }, { nodeMap }) => nodeMap[editingId] || nilFileNode, + dragSourceNode: ({ dragSourceId }, { nodeMap }) => nodeMap[dragSourceId] || nilFileNode, + dragTargetNode: ({ dragTargetId }, { nodeMap }) => { + if (dragTargetId === 'fake') { + return fakeFileNode; + } + return nodeMap[dragTargetId] || nilFileNode; + }, + dragTargetNodeFolder: ({ dragTargetId }, getters) => { + if (dragTargetId === 'fake') { + return getters.rootNode; + } + return getFolder(getters.dragTargetNode, getters); + }, + }, + actions: { + openNode({ + state, + getters, + commit, + dispatch, + }, id) { + const node = getters.nodeMap[id]; + if (node) { + if (node.isFolder && !state.openNodes[id]) { + commit('toggleOpenNode', id); + } + dispatch('openNode', node.item.parentId); + } + }, + openDragTarget: debounceAction(({ state, dispatch }) => { + dispatch('openNode', state.dragTargetId); + }, 1000), + setDragTarget({ commit, getters, dispatch }, node) { + if (!node) { + commit('setDragTargetId'); + } else { + // Make sure target node is not a child of source node + const folderNode = getFolder(node, getters); + const sourceId = getters.dragSourceNode.item.id; + const { nodeMap } = getters; + for (let parentNode = folderNode; + parentNode; + parentNode = nodeMap[parentNode.item.parentId] + ) { + if (parentNode.item.id === sourceId) { + commit('setDragTargetId'); + return; + } + } + + commit('setDragTargetId', node.item.id); + dispatch('openDragTarget'); + } + }, + }, +}; diff --git a/src/store/file.js b/src/store/file.js new file mode 100644 index 0000000..3c06a20 --- /dev/null +++ b/src/store/file.js @@ -0,0 +1,36 @@ +import moduleTemplate from './moduleTemplate'; +import empty from '../data/empties/emptyFile'; + +const module = moduleTemplate(empty); + +module.state = { + ...module.state, + currentId: null, +}; + +module.getters = { + ...module.getters, + current: ({ itemsById, currentId }) => itemsById[currentId] || empty(), + isCurrentTemp: (state, { current }) => current.parentId === 'temp', + lastOpened: ({ itemsById }, { items }, rootState, rootGetters) => + itemsById[rootGetters['data/lastOpenedIds'][0]] || items[0] || empty(), +}; + +module.mutations = { + ...module.mutations, + setCurrentId(state, value) { + state.currentId = value; + }, +}; + +module.actions = { + ...module.actions, + patchCurrent({ getters, commit }, value) { + commit('patchItem', { + ...value, + id: getters.current.id, + }); + }, +}; + +export default module; diff --git a/src/store/findReplace.js b/src/store/findReplace.js new file mode 100644 index 0000000..1c37049 --- /dev/null +++ b/src/store/findReplace.js @@ -0,0 +1,32 @@ +export default { + namespaced: true, + state: { + type: null, + lastOpen: 0, + findText: '', + replaceText: '', + }, + mutations: { + setType: (state, value) => { + state.type = value; + }, + setLastOpen: (state) => { + state.lastOpen = Date.now(); + }, + setFindText: (state, value) => { + state.findText = value; + }, + setReplaceText: (state, value) => { + state.replaceText = value; + }, + }, + actions: { + open({ commit }, { type, findText }) { + commit('setType', type); + if (findText) { + commit('setFindText', findText); + } + commit('setLastOpen'); + }, + }, +}; diff --git a/src/store/folder.js b/src/store/folder.js new file mode 100644 index 0000000..715dc46 --- /dev/null +++ b/src/store/folder.js @@ -0,0 +1,6 @@ +import moduleTemplate from './moduleTemplate'; +import empty from '../data/empties/emptyFolder'; + +const module = moduleTemplate(empty); + +export default module; diff --git a/src/store/img.js b/src/store/img.js new file mode 100644 index 0000000..fe40455 --- /dev/null +++ b/src/store/img.js @@ -0,0 +1,89 @@ +import utils from '../services/utils'; + +const checkStorageLocalKey = 'img/checkedStorage'; +const workspacePathLocalKey = 'img/workspaceImgPath'; + +export default { + namespaced: true, + state: { + // 当前图片上传中的临时ID + currImgId: null, + // 选择的存储图床信息 + checkedStorage: { + type: 'workspace', // 目前存储类型分三种 token 与 tokenRepo 、workspace + provider: null, // 对应是何种账号 + sub: '/imgs/{YYYY}-{MM}-{DD}', // 对应 token 中的sub + sid: null, + }, + // 当前仓库图片存储位置 key 为path value 为true + workspaceImagePath: { + '/imgs/{YYYY}-{MM}-{DD}': true, + }, + }, + mutations: { + setCurrImgId: (state, value) => { + state.currImgId = value; + }, + clearCurrImg: (state) => { + state.currImg = null; + }, + changeCheckedStorage: (state, value) => { + if (value) { + state.checkedStorage = { + type: value.type, // 目前存储类型分两种 token 与 tokenRepo + provider: value.provider, // 对应是何种账号 + sub: value.sub, // 对应 token 中的sub + sid: value.sid, + }; + } else { + state.checkedStorage = { + type: null, // 目前存储类型分两种 token 与 tokenRepo + provider: null, // 对应是何种账号 + sub: null, // 对应 token 中的sub + sid: null, + }; + } + }, + setWorkspaceImgPath: (state, value) => { + state.workspaceImagePath = value; + localStorage.setItem(workspacePathLocalKey, JSON.stringify(state.workspaceImagePath)); + }, + addWorkspaceImgPath: (state, value) => { + state.workspaceImagePath[value] = true; + state.workspaceImagePath = utils.deepCopy(state.workspaceImagePath); + localStorage.setItem(workspacePathLocalKey, JSON.stringify(state.workspaceImagePath)); + }, + removeWorkspaceImgPath: (state, value) => { + delete state.workspaceImagePath[value]; + state.workspaceImagePath = utils.deepCopy(state.workspaceImagePath); + localStorage.setItem(workspacePathLocalKey, JSON.stringify(state.workspaceImagePath)); + }, + }, + getters: { + currImgId: state => state.currImgId, + getCheckedStorage: state => state.checkedStorage, + getCheckedStorageSub: state => state.checkedStorage.sub, + getWorkspaceImgPath: state => state.workspaceImagePath, + }, + actions: { + setCurrImgId({ commit }, imgId) { + commit('setCurrImgId', imgId); + }, + clearImg({ commit }) { + commit('clearCurrImg'); + }, + changeCheckedStorage({ commit }, checkedStorage) { + commit('changeCheckedStorage', checkedStorage); + localStorage.setItem(checkStorageLocalKey, JSON.stringify(checkedStorage)); + }, + setWorkspaceImgPath({ commit }, workspaceImgPath) { + commit('setWorkspaceImgPath', workspaceImgPath); + }, + addWorkspaceImgPath({ commit }, workspaceImgPathValue) { + commit('addWorkspaceImgPath', workspaceImgPathValue); + }, + removeWorkspaceImgPath({ commit }, workspaceImgPathValue) { + commit('removeWorkspaceImgPath', workspaceImgPathValue); + }, + }, +}; diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 0000000..f81e6ac --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,195 @@ +import createLogger from 'vuex/dist/logger'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import utils from '../services/utils'; +import content from './content'; +import contentState from './contentState'; +import contextMenu from './contextMenu'; +import data from './data'; +import discussion from './discussion'; +import explorer from './explorer'; +import file from './file'; +import findReplace from './findReplace'; +import folder from './folder'; +import layout from './layout'; +import modal from './modal'; +import notification from './notification'; +import queue from './queue'; +import syncedContent from './syncedContent'; +import userInfo from './userInfo'; +import workspace from './workspace'; +import img from './img'; +import theme from './theme'; +import locationTemplate from './locationTemplate'; +import emptyPublishLocation from '../data/empties/emptyPublishLocation'; +import emptySyncLocation from '../data/empties/emptySyncLocation'; +import constants from '../data/constants'; + +Vue.use(Vuex); + +const debug = NODE_ENV !== 'production'; + +const store = new Vuex.Store({ + modules: { + content, + contentState, + contextMenu, + data, + discussion, + explorer, + file, + findReplace, + folder, + layout, + modal, + notification, + publishLocation: locationTemplate(emptyPublishLocation), + queue, + syncedContent, + syncLocation: locationTemplate(emptySyncLocation), + userInfo, + workspace, + img, + theme, + }, + state: { + light: false, + offline: false, + lastOfflineCheck: 0, + timeCounter: 0, + }, + mutations: { + setLight: (state, value) => { + state.light = value; + }, + setOffline: (state, value) => { + state.offline = value; + }, + updateLastOfflineCheck: (state) => { + state.lastOfflineCheck = Date.now(); + }, + updateTimeCounter: (state) => { + state.timeCounter += 1; + }, + }, + getters: { + allItemsById: (state) => { + const result = {}; + constants.types.forEach(type => Object.assign(result, state[type].itemsById)); + return result; + }, + pathsByItemId: (state, getters) => { + const result = {}; + const processNode = (node, parentPath = '') => { + let path = parentPath; + if (node.item.id) { + path += node.item.name; + if (node.isTrash) { + path = '.stackedit-trash/'; + } else if (node.isFolder) { + path += '/'; + } + result[node.item.id] = path; + } + + if (node.isFolder) { + node.folders.forEach(child => processNode(child, path)); + node.files.forEach(child => processNode(child, path)); + } + }; + + processNode(getters['explorer/rootNode']); + return result; + }, + itemsByPath: (state, { allItemsById, pathsByItemId }) => { + const result = {}; + Object.entries(pathsByItemId).forEach(([id, path]) => { + const items = result[path] || []; + items.push(allItemsById[id]); + result[path] = items; + }); + return result; + }, + gitPathsByItemId: (state, { allItemsById, pathsByItemId }) => { + const result = {}; + Object.entries(allItemsById).forEach(([id, item]) => { + if (item.type === 'data') { + result[id] = `.stackedit-data/${id}.json`; + } else if (item.type === 'file') { + const filePath = pathsByItemId[id]; + result[id] = `${filePath}.md`; + result[`${id}/content`] = `/${filePath}.md`; + } else if (item.type === 'content') { + const [fileId] = id.split('/'); + const filePath = pathsByItemId[fileId]; + result[fileId] = `${filePath}.md`; + result[id] = `/${filePath}.md`; + } else if (item.type === 'folder') { + result[id] = pathsByItemId[id]; + } else if (item.type === 'syncLocation' || item.type === 'publishLocation') { + // locations are stored as paths + const encodedItem = utils.encodeBase64(utils.serializeObject({ + ...item, + id: undefined, + type: undefined, + fileId: undefined, + hash: undefined, + }), true); + const extension = item.type === 'syncLocation' ? 'sync' : 'publish'; + const path = pathsByItemId[item.fileId]; + if (path) { + result[id] = `${path}.${encodedItem}.${extension}`; + } + } + }); + return result; + }, + itemIdsByGitPath: (state, { gitPathsByItemId }) => { + const result = {}; + Object.entries(gitPathsByItemId).forEach(([id, path]) => { + result[path] = id; + }); + return result; + }, + itemsByGitPath: (state, { allItemsById, gitPathsByItemId }) => { + const result = {}; + Object.entries(gitPathsByItemId).forEach(([id, path]) => { + const item = allItemsById[id]; + if (item) { + result[path] = item; + } + }); + return result; + }, + isSponsor: ({ light }, getters) => { + if (light) { + return true; + } + if (!getters['data/serverConf'].allowSponsorship) { + return true; + } + const sponsorToken = getters['workspace/sponsorToken']; + return sponsorToken ? sponsorToken.isSponsor : false; + }, + }, + actions: { + setOffline: ({ state, commit, dispatch }, value) => { + if (state.offline !== value) { + commit('setOffline', value); + if (state.offline) { + return Promise.reject(new Error('You are offline.')); + } + dispatch('notification/info', 'You are back online!'); + } + return Promise.resolve(); + }, + }, + strict: debug, + plugins: debug ? [createLogger()] : [], +}); + +setInterval(() => { + store.commit('updateTimeCounter'); +}, 30 * 1000); + +export default store; diff --git a/src/store/layout.js b/src/store/layout.js new file mode 100644 index 0000000..5910434 --- /dev/null +++ b/src/store/layout.js @@ -0,0 +1,187 @@ +import pagedownButtons from '../data/pagedownButtons'; + +let buttonCount = 2; // 2 for undo/redo +let spacerCount = 0; +pagedownButtons.forEach((button) => { + if (button.method) { + buttonCount += 1; + } else { + spacerCount += 1; + } +}); + +const minPadding = 25; +const editorTopPadding = 10; +const navigationBarEditButtonsWidth = (34 * buttonCount) + (8 * spacerCount); // buttons + spacers +const navigationBarLeftButtonWidth = 38 + 4 + 12; +const navigationBarRightButtonWidth = 38 + 8; +const navigationBarSpinnerWidth = 24 + 8 + 5; // 5 for left margin +const navigationBarLocationWidth = 20; +const navigationBarSyncPublishButtonsWidth = 34 + 10; +const navigationBarTitleMargin = 8; +const maxTitleMaxWidth = 800; +const minTitleMaxWidth = 200; + +const constants = { + editorMinWidth: 320, + explorerWidth: 260, + gutterWidth: 250, + sideBarWidth: 280, + navigationBarHeight: 44, + buttonBarWidth: 26, + statusBarHeight: 20, +}; + +function computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = { + showNavigationBar: layoutSettings.showNavigationBar + || !layoutSettings.showEditor + || state.content.revisionContent + || state.light, + showStatusBar: layoutSettings.showStatusBar, + showEditor: layoutSettings.showEditor, + showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor, + showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor, + showSideBar: layoutSettings.showSideBar && !state.light, + showExplorer: layoutSettings.showExplorer && !state.light, + layoutOverflow: false, + hideLocations: state.light, +}) { + styles.innerHeight = state.layout.bodyHeight; + if (styles.showNavigationBar) { + styles.innerHeight -= constants.navigationBarHeight; + } + if (styles.showStatusBar) { + styles.innerHeight -= constants.statusBarHeight; + } + + styles.innerWidth = state.layout.bodyWidth; + if (styles.innerWidth < constants.editorMinWidth + + constants.gutterWidth + constants.buttonBarWidth + ) { + styles.layoutOverflow = true; + } + if (styles.showSideBar) { + styles.innerWidth -= constants.sideBarWidth; + } + if (styles.showExplorer) { + styles.innerWidth -= constants.explorerWidth; + } + + let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth; + // No commenting for temp files + const showGutter = !getters['file/isCurrentTemp'] && !!getters['discussion/currentDiscussion']; + if (showGutter) { + doublePanelWidth -= constants.gutterWidth; + } + if (doublePanelWidth < constants.editorMinWidth) { + doublePanelWidth = constants.editorMinWidth; + } + + if (styles.showSidePreview && doublePanelWidth / 2 < constants.editorMinWidth) { + styles.showSidePreview = false; + styles.showPreview = false; + styles.layoutOverflow = false; + return computeStyles(state, getters, layoutSettings, styles); + } + + const computedSettings = getters['data/computedSettings']; + styles.fontSize = 18; + styles.textWidth = 990; + if (doublePanelWidth < 1120) { + styles.fontSize -= 1; + styles.textWidth = 910; + } + if (doublePanelWidth < 1040) { + styles.textWidth = 830; + } + styles.textWidth *= computedSettings.maxWidthFactor; + if (doublePanelWidth < styles.textWidth) { + styles.textWidth = doublePanelWidth; + } + if (styles.textWidth < 640) { + styles.fontSize -= 1; + } + styles.fontSize *= computedSettings.fontSizeFactor; + + const bottomPadding = Math.floor(styles.innerHeight / 2); + const panelWidth = Math.floor(doublePanelWidth / 2); + styles.previewWidth = styles.showSidePreview ? + panelWidth : + doublePanelWidth; + const previewRightPadding = Math + .max(Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding); + if (!styles.showSidePreview) { + styles.previewWidth += constants.buttonBarWidth; + } + styles.previewGutterWidth = showGutter && !layoutSettings.showEditor + ? constants.gutterWidth + : 0; + const previewLeftPadding = previewRightPadding + styles.previewGutterWidth; + styles.previewGutterLeft = previewLeftPadding - minPadding; + styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`; + styles.editorWidth = styles.showSidePreview ? + panelWidth : + doublePanelWidth; + const editorRightPadding = Math + .max(Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); + styles.editorGutterWidth = showGutter && layoutSettings.showEditor + ? constants.gutterWidth + : 0; + const editorLeftPadding = editorRightPadding + styles.editorGutterWidth; + styles.editorGutterLeft = editorLeftPadding - minPadding; + styles.editorPadding = `${editorTopPadding}px ${editorRightPadding}px ${bottomPadding}px ${editorLeftPadding}px`; + + styles.titleMaxWidth = styles.innerWidth - + navigationBarLeftButtonWidth - + navigationBarRightButtonWidth - + navigationBarSpinnerWidth; + if (styles.showEditor) { + const syncLocations = getters['syncLocation/current']; + const publishLocations = getters['publishLocation/current']; + styles.titleMaxWidth -= navigationBarEditButtonsWidth + + (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) + + (navigationBarSyncPublishButtonsWidth * 2) + + navigationBarTitleMargin; + if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) { + styles.hideLocations = true; + } + } + styles.titleMaxWidth = Math + .max(minTitleMaxWidth, Math + .min(maxTitleMaxWidth, styles.titleMaxWidth)); + return styles; +} + +export default { + namespaced: true, + state: { + canUndo: false, + canRedo: false, + bodyWidth: 0, + bodyHeight: 0, + }, + mutations: { + setCanUndo: (state, value) => { + state.canUndo = value; + }, + setCanRedo: (state, value) => { + state.canRedo = value; + }, + updateBodySize: (state) => { + state.bodyWidth = document.body.clientWidth; + state.bodyHeight = document.body.clientHeight; + }, + }, + getters: { + constants: () => constants, + styles: (state, getters, rootState, rootGetters) => computeStyles(rootState, rootGetters), + }, + actions: { + updateBodySize({ commit, dispatch, rootGetters }) { + commit('updateBodySize'); + // Make sure both explorer and side bar are not open if body width is small + const layoutSettings = rootGetters['data/layoutSettings']; + dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true }); + }, + }, +}; diff --git a/src/store/locationTemplate.js b/src/store/locationTemplate.js new file mode 100644 index 0000000..9911383 --- /dev/null +++ b/src/store/locationTemplate.js @@ -0,0 +1,86 @@ +import moduleTemplate from './moduleTemplate'; +import providerRegistry from '../services/providers/common/providerRegistry'; +import utils from '../services/utils'; + +const addToGroup = (groups, item) => { + const list = groups[item.fileId]; + if (!list) { + groups[item.fileId] = [item]; + } else { + list.push(item); + } +}; + +export default (empty) => { + const module = moduleTemplate(empty); + + module.getters = { + ...module.getters, + groupedByFileId: (state, { items }) => { + const groups = {}; + items.forEach(item => addToGroup(groups, item)); + return groups; + }, + groupedByFileIdAndHash: (state, { items }) => { + const fileIdGroups = {}; + items.forEach((item) => { + let hashGroups = fileIdGroups[item.fileId]; + if (!hashGroups) { + hashGroups = {}; + fileIdGroups[item.fileId] = hashGroups; + } + const list = hashGroups[item.hash]; + if (!list) { + hashGroups[item.hash] = [item]; + } else { + list.push(item); + } + }); + return fileIdGroups; + }, + filteredGroupedByFileId: (state, { items }) => { + const groups = {}; + items + .filter((item) => { + // Filter items that we can't use + const provider = providerRegistry.providersById[item.providerId]; + return provider && provider.getToken(item); + }) + .forEach(item => addToGroup(groups, item)); + return groups; + }, + current: (state, { filteredGroupedByFileId }, rootState, rootGetters) => { + const locations = filteredGroupedByFileId[rootGetters['file/current'].id] || []; + return locations.map((location) => { + const provider = providerRegistry.providersById[location.providerId]; + return { + ...location, + description: utils.sanitizeName(provider.getLocationDescription(location)), + url: provider.getLocationUrl(location), + }; + }); + }, + currentWithWorkspaceSyncLocation: (state, { current }, rootState, rootGetters) => { + const fileId = rootGetters['file/current'].id; + const fileSyncData = rootGetters['data/syncDataByItemId'][fileId]; + const contentSyncData = rootGetters['data/syncDataByItemId'][`${fileId}/content`]; + if (!fileSyncData || !contentSyncData) { + return current; + } + + // Add the workspace sync location + const workspaceProvider = providerRegistry.providersById[ + rootGetters['workspace/currentWorkspace'].providerId]; + return [{ + id: 'main', + providerId: workspaceProvider.id, + fileId, + description: utils.sanitizeName(workspaceProvider + .getSyncDataDescription(fileSyncData, contentSyncData)), + url: workspaceProvider.getSyncDataUrl(fileSyncData, contentSyncData), + }, ...current]; + }, + }; + + return module; +}; diff --git a/src/store/modal.js b/src/store/modal.js new file mode 100644 index 0000000..632717d --- /dev/null +++ b/src/store/modal.js @@ -0,0 +1,40 @@ +export default { + namespaced: true, + state: { + stack: [], + hidden: false, + }, + mutations: { + setStack: (state, value) => { + state.stack = value; + }, + setHidden: (state, value) => { + state.hidden = value; + }, + }, + getters: { + config: ({ hidden, stack }) => !hidden && stack[0], + }, + actions: { + async open({ commit, state }, param) { + const config = typeof param === 'object' ? { ...param } : { type: param }; + try { + return await new Promise((resolve, reject) => { + config.resolve = resolve; + config.reject = reject; + commit('setStack', [config, ...state.stack]); + }); + } finally { + commit('setStack', state.stack.filter((otherConfig => otherConfig !== config))); + } + }, + async hideUntil({ commit }, promise) { + try { + commit('setHidden', true); + return await promise; + } finally { + commit('setHidden', false); + } + }, + }, +}; diff --git a/src/store/moduleTemplate.js b/src/store/moduleTemplate.js new file mode 100644 index 0000000..90fcfa6 --- /dev/null +++ b/src/store/moduleTemplate.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import utils from '../services/utils'; + +export default (empty, simpleHash = false) => { + // Use Date.now() as a simple hash function, which is ok for not-synced types + const hashFunc = simpleHash ? Date.now : item => utils.getItemHash(item); + + return { + namespaced: true, + state: { + itemsById: {}, + }, + getters: { + items: ({ itemsById }) => Object.values(itemsById), + }, + mutations: { + setItem(state, value) { + const item = Object.assign(empty(value.id), value); + if (!item.hash || !simpleHash) { + item.hash = hashFunc(item); + } + Vue.set(state.itemsById, item.id, item); + }, + patchItem(state, patch) { + const item = state.itemsById[patch.id]; + if (item) { + Object.assign(item, patch); + item.hash = hashFunc(item); + Vue.set(state.itemsById, item.id, item); + return true; + } + return false; + }, + deleteItem(state, id) { + Vue.delete(state.itemsById, id); + }, + }, + actions: {}, + }; +}; diff --git a/src/store/notification.js b/src/store/notification.js new file mode 100644 index 0000000..9f33783 --- /dev/null +++ b/src/store/notification.js @@ -0,0 +1,90 @@ +import providerRegistry from '../services/providers/common/providerRegistry'; +import utils from '../services/utils'; + +const defaultTimeout = 5000; // 5 sec + +export default { + namespaced: true, + state: { + items: [], + }, + mutations: { + setItems: (state, value) => { + state.items = value; + }, + }, + actions: { + showItem({ state, commit }, item) { + const existingItem = utils.someResult( + state.items, + other => other.type === item.type && other.content === item.content && item, + ); + if (existingItem) { + return existingItem.promise; + } + + item.promise = new Promise((resolve, reject) => { + commit('setItems', [...state.items, item]); + const removeItem = () => commit( + 'setItems', + state.items.filter(otherItem => otherItem !== item), + ); + setTimeout( + () => removeItem(), + item.timeout || defaultTimeout, + ); + item.resolve = (res) => { + removeItem(); + resolve(res); + }; + item.reject = (err) => { + removeItem(); + reject(err); + }; + }); + + return item.promise; + }, + info({ dispatch }, content) { + return dispatch('showItem', { + type: 'info', + content, + }); + }, + badge({ dispatch }, content) { + return dispatch('showItem', { + type: 'badge', + content, + }); + }, + confirm({ dispatch }, content) { + return dispatch('showItem', { + type: 'confirm', + content, + timeout: 10000, // 10 sec + }); + }, + error({ dispatch, rootState }, error) { + const item = { type: 'error' }; + if (error) { + if (error.message) { + item.content = error.message; + } else if (error.status) { + const location = rootState.queue.currentLocation; + if (location.providerId) { + const provider = providerRegistry.providersById[location.providerId]; + item.content = `HTTP error ${error.status} on ${provider.name} location.`; + } else { + item.content = `HTTP error ${error.status}.`; + } + } else { + item.content = `${error}`; + } + } + if (!item.content || item.content === '[object Object]') { + item.content = 'Unknown error.'; + } + return dispatch('showItem', item); + }, + }, +}; diff --git a/src/store/queue.js b/src/store/queue.js new file mode 100644 index 0000000..084a86a --- /dev/null +++ b/src/store/queue.js @@ -0,0 +1,83 @@ +const setter = propertyName => (state, value) => { + state[propertyName] = value; +}; + +let queue = Promise.resolve(); + +export default { + namespaced: true, + state: { + isEmpty: true, + isSyncRequested: false, + isPublishRequested: false, + currentLocation: {}, + }, + mutations: { + setIsEmpty: setter('isEmpty'), + setIsSyncRequested: setter('isSyncRequested'), + setIsPublishRequested: setter('isPublishRequested'), + setCurrentLocation: setter('currentLocation'), + }, + actions: { + enqueue({ state, commit, dispatch }, cb) { + if (state.offline) { + // No need to enqueue + return; + } + const checkOffline = () => { + if (state.offline) { + // Empty queue + queue = Promise.resolve(); + commit('setIsEmpty', true); + throw new Error('offline'); + } + }; + if (state.isEmpty) { + commit('setIsEmpty', false); + } + const newQueue = queue + .then(() => checkOffline()) + .then(() => Promise.resolve() + .then(() => cb()) + .catch((err) => { + console.error(err); // eslint-disable-line no-console + checkOffline(); + dispatch('notification/error', err, { root: true }); + }) + .then(() => { + if (newQueue === queue) { + commit('setIsEmpty', true); + } + })); + queue = newQueue; + }, + enqueueSyncRequest({ state, commit, dispatch }, cb) { + if (!state.isSyncRequested) { + commit('setIsSyncRequested', true); + const unset = () => commit('setIsSyncRequested', false); + dispatch('enqueue', () => cb().then(unset, (err) => { + unset(); + throw err; + })); + } + }, + enqueuePublishRequest({ state, commit, dispatch }, cb) { + if (!state.isSyncRequested) { + commit('setIsPublishRequested', true); + const unset = () => commit('setIsPublishRequested', false); + dispatch('enqueue', () => cb().then(unset, (err) => { + unset(); + throw err; + })); + } + }, + async doWithLocation({ commit }, { location, action }) { + try { + commit('setCurrentLocation', location); + return await action(); + } finally { + commit('setCurrentLocation', {}); + } + }, + }, +}; diff --git a/src/store/syncedContent.js b/src/store/syncedContent.js new file mode 100644 index 0000000..471c89f --- /dev/null +++ b/src/store/syncedContent.js @@ -0,0 +1,12 @@ +import moduleTemplate from './moduleTemplate'; +import empty from '../data/empties/emptySyncedContent'; + +const module = moduleTemplate(empty, true); + +module.getters = { + ...module.getters, + current: ({ itemsById }, getters, rootState, rootGetters) => + itemsById[`${rootGetters['file/current'].id}/syncedContent`] || empty(), +}; + +export default module; diff --git a/src/store/theme.js b/src/store/theme.js new file mode 100644 index 0000000..6509aaa --- /dev/null +++ b/src/store/theme.js @@ -0,0 +1,138 @@ +const localKey = 'theme/currEditTheme'; +const customEditThemeKey = 'theme/customEditThemeStyle'; +const previewLocalKey = 'theme/currPreviewTheme'; +const customPreviewThemeKey = 'theme/customPreviewThemeStyle'; + +export default { + namespaced: true, + state: { + // 当前编辑主题 + currEditTheme: '', + customEditThemeStyle: null, + // 当前预览主题 + currPreviewTheme: '', + customPreviewThemeStyle: null, + }, + mutations: { + setEditTheme: (state, value) => { + state.currEditTheme = value; + }, + setCustomEditThemeStyle: (state, value) => { + state.customEditThemeStyle = value; + }, + setPreviewTheme: (state, value) => { + state.currPreviewTheme = value; + }, + setCustomPreviewThemeStyle: (state, value) => { + state.customPreviewThemeStyle = value; + }, + }, + getters: { + currEditTheme: state => state.currEditTheme, + customEditThemeStyle: state => state.customEditThemeStyle, + currPreviewTheme: state => state.currPreviewTheme, + customPreviewThemeStyle: state => state.customPreviewThemeStyle, + }, + actions: { + async setEditTheme({ commit }, theme) { + // 如果不是default 则加载样式 + if (!theme || theme === 'default') { + commit('setEditTheme', theme); + localStorage.setItem(localKey, theme); + return; + } + const themeStyle = document.getElementById(`edit-theme-${theme}`); + if (themeStyle) { + commit('setEditTheme', theme); + localStorage.setItem(localKey, theme); + return; + } + // 如果是自定义则直接追加 + if (theme === 'custom') { + const styleEle = document.createElement('style'); + styleEle.id = `edit-theme-${theme}`; + styleEle.type = 'text/css'; + styleEle.innerHTML = localStorage.getItem(customEditThemeKey) || ''; + commit('setCustomEditThemeStyle', styleEle.innerHTML); + document.head.appendChild(styleEle); + commit('setEditTheme', theme); + localStorage.setItem(localKey, theme); + return; + } + const script = document.createElement('script'); + let timeout; + try { + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + script.src = `/themes/edit-theme-${theme}.js`; + try { + document.head.appendChild(script); + timeout = setTimeout(reject, 30); + commit('setEditTheme', theme); + localStorage.setItem(localKey, theme); + } catch (e) { + reject(e); + } + }); + } finally { + clearTimeout(timeout); + document.head.removeChild(script); + } + }, + setCustomEditThemeStyle({ commit }, value) { + commit('setCustomEditThemeStyle', value); + localStorage.setItem(customEditThemeKey, value); + }, + async setPreviewTheme({ commit }, theme) { + // 如果不是default 则加载样式 + if (!theme || theme === 'default') { + commit('setPreviewTheme', theme); + localStorage.setItem(previewLocalKey, theme); + return; + } + const themeStyle = document.getElementById(`preview-theme-${theme}`); + if (themeStyle) { + commit('setPreviewTheme', theme); + localStorage.setItem(previewLocalKey, theme); + return; + } + // 如果是自定义则直接追加 + if (theme === 'custom') { + const styleEle = document.createElement('style'); + styleEle.id = `preview-theme-${theme}`; + styleEle.type = 'text/css'; + styleEle.innerHTML = localStorage.getItem(customPreviewThemeKey) || ''; + commit('setCustomPreviewThemeStyle', styleEle.innerHTML); + document.head.appendChild(styleEle); + commit('setPreviewTheme', theme); + localStorage.setItem(previewLocalKey, theme); + return; + } + const script = document.createElement('script'); + let timeout; + try { + await new Promise((resolve, reject) => { + script.onload = resolve; + script.onerror = reject; + script.src = `/themes/preview-theme-${theme}.js`; + try { + document.head.appendChild(script); + timeout = setTimeout(reject, 30); + commit('setPreviewTheme', theme); + localStorage.setItem(previewLocalKey, theme); + } catch (e) { + reject(e); + } + }); + } finally { + clearTimeout(timeout); + document.head.removeChild(script); + } + }, + setCustomPreviewThemeStyle({ commit }, value) { + commit('setCustomPreviewThemeStyle', value); + localStorage.setItem(customPreviewThemeKey, value); + }, + }, +}; diff --git a/src/store/userInfo.js b/src/store/userInfo.js new file mode 100644 index 0000000..dabfa9a --- /dev/null +++ b/src/store/userInfo.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +export default { + namespaced: true, + state: { + itemsById: {}, + }, + mutations: { + setItem: ({ itemsById }, item) => { + const itemToSet = { + ...item, + }; + const existingItem = itemsById[item.id]; + if (existingItem) { + if (!itemToSet.name) { + itemToSet.name = existingItem.name; + } + if (!itemToSet.imageUrl) { + itemToSet.imageUrl = existingItem.imageUrl; + } + } + Vue.set(itemsById, item.id, itemToSet); + }, + }, +}; diff --git a/src/store/workspace.js b/src/store/workspace.js new file mode 100644 index 0000000..c71e6cb --- /dev/null +++ b/src/store/workspace.js @@ -0,0 +1,150 @@ +import utils from '../services/utils'; +import providerRegistry from '../services/providers/common/providerRegistry'; + +export default { + namespaced: true, + state: { + currentWorkspaceId: null, + lastFocus: 0, + }, + mutations: { + setCurrentWorkspaceId: (state, value) => { + state.currentWorkspaceId = value; + }, + setLastFocus: (state, value) => { + state.lastFocus = value; + }, + }, + getters: { + workspacesById: (state, getters, rootState, rootGetters) => { + const workspacesById = {}; + const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken']; + Object.entries(rootGetters['data/workspaces']).forEach(([id, workspace]) => { + const sanitizedWorkspace = { + id, + providerId: (mainWorkspaceToken && mainWorkspaceToken.providerId) || 'giteeAppData', + sub: mainWorkspaceToken && mainWorkspaceToken.sub, + ...workspace, + }; + // Filter workspaces that don't have a provider + const workspaceProvider = providerRegistry.providersById[sanitizedWorkspace.providerId]; + if (workspaceProvider) { + // Build the url with the current hostname + const params = workspaceProvider.getWorkspaceParams(sanitizedWorkspace); + sanitizedWorkspace.url = utils.addQueryParams('app', params, true); + sanitizedWorkspace.locationUrl = workspaceProvider + .getWorkspaceLocationUrl(sanitizedWorkspace); + workspacesById[id] = sanitizedWorkspace; + } + }); + return workspacesById; + }, + mainWorkspace: (state, { workspacesById }) => workspacesById.main, + currentWorkspace: ({ currentWorkspaceId }, { workspacesById, mainWorkspace }) => + workspacesById[currentWorkspaceId] || mainWorkspace, + currentWorkspaceIsGit: (state, { currentWorkspace }) => + currentWorkspace.providerId === 'githubWorkspace' + || currentWorkspace.providerId === 'giteeWorkspace' + || currentWorkspace.providerId === 'gitlabWorkspace' + || currentWorkspace.providerId === 'giteaWorkspace' + || currentWorkspace.providerId === 'giteeAppData' + || currentWorkspace.providerId === 'githubAppData', + currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) => + currentWorkspace.providerId === 'githubWorkspace' + || currentWorkspace.providerId === 'giteeWorkspace' + || currentWorkspace.providerId === 'gitlabWorkspace' + || currentWorkspace.providerId === 'giteaWorkspace' + || currentWorkspace.providerId === 'giteeAppData' + || currentWorkspace.providerId === 'githubAppData', + lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`, + lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`, + mainWorkspaceToken: (state, getters, rootState, rootGetters) => + utils.someResult([...Object.values(rootGetters['data/giteeTokensBySub']), ...Object.values(rootGetters['data/githubTokensBySub'])], (token) => { + if (token.isLogin) { + return token; + } + return null; + }), + syncToken: (state, { currentWorkspace, mainWorkspaceToken }, rootState, rootGetters) => { + switch (currentWorkspace.providerId) { + case 'googleDriveWorkspace': + return rootGetters['data/googleTokensBySub'][currentWorkspace.sub]; + case 'githubWorkspace': + return rootGetters['data/githubTokensBySub'][currentWorkspace.sub]; + case 'giteeWorkspace': + return rootGetters['data/giteeTokensBySub'][currentWorkspace.sub]; + case 'gitlabWorkspace': + return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub]; + case 'giteaWorkspace': + return rootGetters['data/giteaTokensBySub'][currentWorkspace.sub]; + case 'couchdbWorkspace': + return rootGetters['data/couchdbTokensBySub'][currentWorkspace.id]; + default: + return mainWorkspaceToken; + } + }, + loginType: (state, { currentWorkspace }) => { + switch (currentWorkspace.providerId) { + case 'googleDriveWorkspace': + return 'google'; + case 'githubAppData': + case 'githubWorkspace': + return 'github'; + case 'giteeAppData': + case 'giteeWorkspace': + default: + return 'gitee'; + case 'gitlabWorkspace': + return 'gitlab'; + case 'giteaWorkspace': + return 'gitea'; + } + }, + loginToken: (state, { loginType, currentWorkspace }, rootState, rootGetters) => { + const tokensBySub = rootGetters['data/tokensByType'][loginType]; + return tokensBySub && tokensBySub[currentWorkspace.sub]; + }, + sponsorToken: (state, { mainWorkspaceToken }) => mainWorkspaceToken, + }, + actions: { + removeWorkspace: ({ commit, rootGetters }, id) => { + const workspaces = { + ...rootGetters['data/workspaces'], + }; + delete workspaces[id]; + commit( + 'data/setItem', + { id: 'workspaces', data: workspaces }, + { root: true }, + ); + }, + patchWorkspacesById: ({ commit, rootGetters }, workspaces) => { + const sanitizedWorkspaces = {}; + Object + .entries({ + ...rootGetters['data/workspaces'], + ...workspaces, + }) + .forEach(([id, workspace]) => { + sanitizedWorkspaces[id] = { + ...workspace, + id, + // Do not store urls + url: undefined, + locationUrl: undefined, + }; + }); + + commit( + 'data/setItem', + { id: 'workspaces', data: sanitizedWorkspaces }, + { root: true }, + ); + }, + setCurrentWorkspaceId: ({ commit, getters }, value) => { + commit('setCurrentWorkspaceId', value); + const lastFocus = parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0; + commit('setLastFocus', lastFocus); + }, + }, +}; diff --git a/src/styles/app.scss b/src/styles/app.scss new file mode 100644 index 0000000..b3356fe --- /dev/null +++ b/src/styles/app.scss @@ -0,0 +1,435 @@ +@import './variables.scss'; + +body { + background-color: #fff; + top: 0; + right: 0; + bottom: 0; + left: 0; + position: fixed; + tab-size: 4; + text-rendering: auto; + + /* Prevent body overscroll on Chrome */ + overflow: hidden; + -webkit-overflow-scrolling: touch; +} + +* { + box-sizing: border-box; +} + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar { + background-color: transparent; + + /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ + &:horizontal { + height: 8px; + } + + /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ + &:vertical { + width: 8px; + } +} + +::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: #bbb; + + .app--dark & { + background-color: #666; + } +} + +:focus { + outline: none; +} + +input[type=checkbox] { + outline: #349be8 auto 5px; +} + +.icon { + width: 100%; + height: 100%; + display: block; + + * { + fill: currentColor; + } +} + +.table-wrapper { + max-width: 100%; + overflow: auto; +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.text-input { + display: block; + font-variant-ligatures: no-common-ligatures; + width: 100%; + height: 36px; + padding: 3px 12px; + font-size: inherit; + line-height: 1.5; + color: inherit; + background-color: rgba(255, 255, 255, 0.8); + background-image: none; + border: 0; + border-radius: $border-radius-base; + + .app--dark & { + background-color: rgba(0, 0, 0, 0.2); + } +} + +.button { + color: #333; + background-color: transparent; + display: inline-block; + height: auto; + padding: 8px 16px; + font-size: 17px; + font-weight: 400; + line-height: 1.4; + text-transform: uppercase; + overflow: hidden; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 0; + border-radius: $border-radius-base; + text-decoration: none; + + &:active, + &:focus, + &:hover, + .hidden-file:focus + & { + color: #333; + background-color: rgba(0, 0, 0, 0.05); + outline: 0; + text-decoration: none; + } + + .app--dark & { + color: rgba(255, 255, 255, 0.75); + + &:active, + &:focus, + &:hover, + .hidden-file:focus + & { + color: rgba(255, 255, 255, 0.75); + background-color: rgba(255, 255, 255, 0.05); + outline: 0; + text-decoration: none; + } + } + + .app--dark .layout__panel--editor &, + .app--dark .layout__panel--preview & { + color: #ccc; + + &:active, + &:focus, + &:hover { + color: #ccc; + background-color: rgba(255, 255, 255, 0.067); + } + } + + &[disabled] { + &, + &:active, + &:focus, + &:hover { + opacity: 0.33; + background-color: transparent; + cursor: not-allowed; + } + } +} + +.button--resolve { + background-color: #349be8; + color: #fff; + margin: -2px 0 -2px 4px; + padding: 10px 20px; + font-size: 18px; + + &:active, + &:focus, + &:hover { + color: #fff; + background-color: darken(#349be8, 8%); + } + + .app--dark & { + background-color: #567c98; + color: rgb(222, 222, 222); + + &:active, + &:focus, + &:hover { + color: #fff; + background-color: darken(#567c98, 8%); + } + } +} + +.textfield { + background-color: #fff; + border: 0; + font-family: inherit; + font-weight: 400; + font-size: 1.05em; + padding: 0 0.6rem; + box-sizing: border-box; + width: 100%; + max-width: 100%; + color: inherit; + height: 2.4rem; + + &:focus { + outline: none; + } + + &[disabled] { + cursor: not-allowed; + background-color: #f0f0f0; + color: #999; + } + + .app--dark & { + background-color: rgba(0, 0, 0, 0.2); + + &[disabled] { + cursor: not-allowed; + background-color: #373737; + color: #999; + } + } +} + +.flex { + display: flex; +} + +.flex--row { + flex-direction: row; +} + +.flex--column { + flex-direction: column; +} + +.flex--center { + justify-content: center; +} + +.flex--end { + justify-content: flex-end; +} + +.flex--space-between { + justify-content: space-between; +} + +.flex--align-center { + align-items: center; +} + +.flex--align-end { + align-items: flex-end; +} + +.user-name { + font-weight: 600; +} + +.side-title { + height: 44px; + line-height: 36px; + padding: 4px 4px 0; + background-color: rgba(0, 0, 0, 0.1); + flex: none; +} + +.side-title__button { + width: 38px; + height: 36px; + padding: 6px; + display: inline-block; + background-color: transparent; + opacity: 0.75; + flex: none; + + /* prevent from seeing wrapped buttons */ + margin-bottom: 20px; + + &:active, + &:focus, + &:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.1); + } +} + +.side-title__title { + text-transform: uppercase; + padding: 0 5px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + width: 100%; +} + +.logo-background { + background: no-repeat center url('../assets/logo.svg'); + background-size: contain; +} + +.gutter { + position: absolute; + top: 0; + height: 100%; +} + +.gutter__background { + position: absolute; + height: 100%; + right: 0; +} + +.new-discussion-button { + color: rgba(0, 0, 0, 0.33); + position: absolute; + left: 0; + padding: 3px 3px 3px 0; + width: 22px; + height: 21px; + line-height: 1; + + .app--dark & { + color: rgba(255, 255, 255, 0.33); + } + + &:active, + &:focus, + &:hover { + color: rgba(0, 0, 0, 0.4); + + .app--dark & { + color: rgba(255, 255, 255, 0.4); + } + } +} + +.discussion-editor-highlighting, +.discussion-preview-highlighting { + background-color: mix($editor-background-light, $selection-highlighting-color, 70%); + padding: 0.25em 0; + + .app--dark & { + background-color: mix($editor-background-dark, $selection-highlighting-color, 70%); + } +} + +.discussion-editor-highlighting--hover, +.discussion-preview-highlighting--hover { + background-color: mix($editor-background-light, $selection-highlighting-color, 50%); + + .app--dark & { + background-color: mix($editor-background-dark, $selection-highlighting-color, 50%); + } + + * { + background-color: transparent; + } +} + +.discussion-editor-highlighting--selected, +.discussion-preview-highlighting--selected { + background-color: mix($editor-background-light, $selection-highlighting-color, 20%); + + .app--dark & { + background-color: mix($editor-background-dark, $selection-highlighting-color, 20%); + } + + * { + background-color: transparent; + } +} + +.discussion-preview-highlighting { + cursor: pointer; + + &.discussion-preview-highlighting--selected { + cursor: auto; + } +} + +.hidden-rendering-container { + position: absolute; + width: 500px; + left: -1000px; +} + +@media print { + body { + background-color: transparent !important; + color: #000 !important; // Black prints faster + overflow: visible !important; + position: absolute !important; + + div { + display: none !important; + } + + a { + text-decoration: underline; + } + } + + body > .app, + body > .app > .layout, + body > .app > .layout > .layout__panel, + body > .app > .layout > .layout__panel > .layout__panel, + body > .app > .layout > .layout__panel > .layout__panel > .layout__panel, + body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview, + body > .app > .layout > .layout__panel > .layout__panel > .layout__panel > .layout__panel--preview div { + background-color: transparent !important; + display: block !important; + height: auto !important; + overflow: visible !important; + position: static !important; + width: auto !important; + font-size: 16px; + } + + .preview__inner-2 { + padding: 0 50px !important; + } + // scss-lint:enable ImportantRule +} diff --git a/src/styles/base.scss b/src/styles/base.scss new file mode 100644 index 0000000..f8e5269 --- /dev/null +++ b/src/styles/base.scss @@ -0,0 +1,310 @@ +@import '../../node_modules/normalize-scss/sass/normalize'; +@import './variables'; + +@include normalize(); + +html, +body { + color: $body-color-light; + font-size: 16px; + font-family: $font-family-main; + font-variant-ligatures: common-ligatures; + line-height: $line-height-base; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + .app--dark { + color: $body-color-dark; + } +} + +.app--dark .layout__panel--editor, +.app--dark .layout__panel--preview { + color: $body-color-dark; +} + +.preview-toc ul { + list-style-type: none; + margin-bottom: 15px; +} + +p, +blockquote, +pre, +ul, +ol, +dl { + margin: 0 0 1.1em; +} + +ul, +ol { + padding-left: 30px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 1.2em 0 0.9em; + line-height: $line-height-title; +} + +h1, +h2 { + &::after { + content: ''; + display: block; + position: relative; + top: 0.33em; + border-bottom: 1px solid $hr-color; + } +} + +ol ul, +ul ol, +ul ul, +ol ol { + margin: 0 0 1.1em; +} + +dt { + font-weight: bold; +} + +a { + color: $link-color; + text-decoration: none; + text-decoration-skip: ink; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +code, +pre, +samp { + font-family: $font-family-monospace; + font-size: $font-size-monospace; + + * { + font-size: inherit; + } +} + +blockquote { + color: rgba(0, 0, 0, 0.5); + padding: 0.5em 1em; + border-left: 8px solid #cfd1d6; + background: #e2e4e9; + word-break: break-word !important; + + :last-child { + margin-bottom: 0; + } + + .app--dark .layout__panel--editor &, + .app--dark .layout__panel--preview & { + color: rgba(255, 255, 255, 0.4); + border-left-color: #333; + background: rgba(51, 51, 51, 0.5); + } +} + +code { + background-color: $code-bg; + border-radius: $border-radius-base; + padding: 2px 4px; + + .app--dark & { + background-color: $code-dark-bg; + } +} + +hr { + border: 0; + border-top: 1px solid $hr-color; + margin: 2em 0; +} + +pre > code { + background-color: $code-bg; + display: block; + padding: 0.5em; + -webkit-text-size-adjust: none; + overflow-x: auto; + white-space: pre; + + .app--dark & { + background-color: $code-dark-bg; + } +} + +.toc ul { + list-style-type: none; + padding-left: 20px; +} + +table { + background-color: transparent; + border-collapse: collapse; + border-spacing: 0; + margin-bottom: 1em; +} + +td, +th { + border: 1px solid #c9c9c9; + padding: 8px 12px; + + .app--dark & { + border: 1px solid #5f5f5f; + } +} + +td { + border: 1px solid #c9c9c9; + + .app--dark & { + border: 1px solid #5f5f5f; + } +} + +mark { + background-color: #f8f840; +} + +kbd { + font-family: $font-family-main; + background-color: #fff; + border: 1px solid rgba(63, 63, 63, 0.25); + border-radius: 3px; + box-shadow: 0 1px 0 rgba(63, 63, 63, 0.25); + color: #333; + display: inline-block; + font-size: 0.8em; + margin: 0 0.1em; + padding: 0.1em 0.6em; + white-space: nowrap; +} + +abbr { + &[title] { + border-bottom: 1px dotted #777; + cursor: help; + } +} + +img { + max-width: 100%; +} + +.task-list-item { + list-style-type: none; +} + +.task-list-item-checkbox { + margin: 0 0.2em 0 -1.3em; +} + +.footnote { + font-size: 0.8em; + position: relative; + top: -0.25em; + vertical-align: top; +} + +.page-break-after { + page-break-after: always; +} + +.abc-notation-block { + overflow-x: auto !important; +} + +.stackedit__html { + margin-bottom: 180px; + margin-left: auto; + margin-right: auto; + padding-left: 30px; + padding-right: 30px; + max-width: 750px; +} + +.stackedit__toc { + ul { + padding: 0; + + a { + margin: 0.5rem 0; + padding: 0.5rem 1rem; + } + + ul { + color: #888; + font-size: 0.9em; + + a { + margin: 0; + padding: 0.1rem 1rem; + } + } + } + + li { + display: block; + } + + a { + display: block; + color: inherit; + text-decoration: none; + + &:active, + &:focus, + &:hover { + background-color: rgba(0, 0, 0, 0.075); + border-radius: $border-radius-base; + } + } +} + +.stackedit__left { + position: fixed; + display: none; + width: 250px; + height: 100%; + top: 0; + left: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: none; + + @media (min-width: 1060px) { + display: block; + } +} + +.stackedit__right { + position: absolute; + right: 0; + top: 0; + left: 0; + + @media (min-width: 1060px) { + left: 250px; + } +} + +.stackedit--pdf { + .stackedit__html { + padding-left: 0; + padding-right: 0; + max-width: none; + } +} diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss new file mode 100644 index 0000000..9769705 --- /dev/null +++ b/src/styles/fonts.scss @@ -0,0 +1,41 @@ +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + src: url('../assets/fonts/lato-normal.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: italic; + font-weight: 400; + src: url('../assets/fonts/lato-normal-italic.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 600; + src: url('../assets/fonts/lato-black.woff') format('woff'); +} + +@font-face { + font-family: 'Lato'; + font-style: italic; + font-weight: 600; + src: url('../assets/fonts/lato-black-italic.woff') format('woff'); +} + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: url('../assets/fonts/RobotoMono-Regular.woff') format('woff'); +} + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 600; + src: url('../assets/fonts/RobotoMono-Bold.woff') format('woff'); +} diff --git a/src/styles/index.js b/src/styles/index.js new file mode 100644 index 0000000..f9da46e --- /dev/null +++ b/src/styles/index.js @@ -0,0 +1,4 @@ +import 'katex/dist/katex.css'; +import './fonts.scss'; +import './prism.scss'; +import './base.scss'; diff --git a/src/styles/markdownHighlighting.scss b/src/styles/markdownHighlighting.scss new file mode 100644 index 0000000..c992f2b --- /dev/null +++ b/src/styles/markdownHighlighting.scss @@ -0,0 +1,355 @@ +@import './variables'; + +.markdown-highlighting { + color: $editor-color-light; + caret-color: $editor-color-light-low; + + .app--dark & { + color: $editor-color-dark; + caret-color: $editor-color-dark-low; + } + + font-family: inherit; + font-size: inherit; + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; + font-weight: $editor-font-weight-base; + + .code { + font-family: $font-family-monospace; + font-size: $font-size-monospace; + + * { + font-size: inherit !important; + } + } + + .pre { + color: $editor-color-light; + + .app--dark & { + color: $editor-color-dark; + } + + font-family: $font-family-monospace; + font-size: $font-size-monospace; + + [class*='language-'] { + color: $editor-color-light-low; + + .app--dark & { + color: $editor-color-dark-low; + } + } + + * { + font-size: inherit !important; + } + + &, + * { + line-height: $line-height-title; + } + } + + .tag { + color: $editor-color-light; + + .app--dark & { + color: $editor-color-dark; + } + + font-family: $font-family-monospace; + font-size: $font-size-monospace; + font-weight: $editor-font-weight-bold; + + .punctuation, + .attr-value, + .attr-name { + font-weight: $editor-font-weight-base; + } + + * { + font-size: inherit !important; + } + } + + .latex, + .math { + font-family: $font-family-monospace; + color: $editor-color-light; + + .app--dark & { + color: $editor-color-dark; + } + } + + .entity { + color: $editor-color-light; + + .app--dark & { + color: $editor-color-dark; + } + + font-family: $font-family-monospace; + font-size: $font-size-monospace; + font-style: italic; + + * { + font-size: inherit !important; + } + } + + .table { + font-family: $font-family-monospace; + font-size: $font-size-monospace; + + * { + font-size: inherit !important; + } + } + + .comment { + color: $editor-color-light-high; + + .app--dark & { + color: $editor-color-dark-high; + } + } + + .keyword { + color: $editor-color-light-low; + + .app--dark & { + color: $editor-color-dark-low; + } + + font-weight: $editor-font-weight-bold; + } + + .code, + .img, + .img-wrapper, + .imgref, + .cl-toc { + background-color: $code-bg; + border-radius: $code-border-radius; + padding: 0.15em 0; + + .app--dark & { + background-color: $code-dark-bg; + } + } + + .img-wrapper { + display: inline-block; + + .img { + display: inline-block; + padding: 0; + background-color: transparent; + } + + img { + max-width: 100%; + padding: 0 0.15em; + box-sizing: content-box; + } + } + + .cl-toc { + font-size: 2.8em; + padding: 0.15em; + } + + .blockquote { + color: $editor-color-light-blockquote; + + .app--dark & { + color: $editor-color-dark-blockquote; + } + } + + .h1, + .h11, + .h2, + .h22, + .h3, + .h4, + .h5, + .h6 { + font-weight: $editor-font-weight-bold; + + &, + * { + line-height: $line-height-title; + } + } + + .h1, + .h11 { + font-size: 1.7em; + } + + .h2, + .h22 { + font-size: 1.4em; + } + + .h3 { + font-size: 1.2em; + } + + .h4 { + font-size: 1.1em; + } + + .h5 { + font-size: 1em; + } + + .h6 { + font-size: 0.9em; + } + + .cl-hash { + color: $editor-color-light-high; + + .app--dark & { + color: $editor-color-dark-high; + } + } + + .cl, + .hr { + color: $editor-color-light-high; + + .app--dark & { + color: $editor-color-dark-high; + } + + font-style: normal; + font-weight: $editor-font-weight-base; + } + + .em, + .em .cl { + font-style: italic; + } + + .strong, + .strong .cl, + .term { + font-weight: $editor-font-weight-bold; + } + + .cl-del-text { + text-decoration: line-through; + } + + .cl-mark-text { + background-color: #f8f840; + color: $editor-color-light-low; + } + + .url, + .email, + .cl-underlined-text { + text-decoration: underline; + color: #d7a55b; + } + + .linkdef .url { + color: $editor-color-light-high; + + .app--dark & { + color: $editor-color-dark-high; + } + } + + .fn, + .inlinefn, + .sup { + font-size: smaller; + position: relative; + top: -0.5em; + } + + .sub { + bottom: -0.25em; + font-size: smaller; + position: relative; + } + + .img, + .imgref, + .link, + .linkref { + color: $editor-color-light-high; + + .app--dark & { + color: $editor-color-dark-high; + } + + .cl-underlined-text { + color: #d7a55b; + } + } + + .cl-title { + color: $editor-color-light; + + .app--dark & { + color: $editor-color-dark; + } + } + + .cn-head { + color: #dea731; + + .app--dark & { + color: #f8bb39; + } + } + + .cn-strong { + color: #db784d; + + .app--dark & { + color: #db784d; + } + } + + .cn-code { + color: #59b003; + + .app--dark & { + color: #95cc5e; + } + } + + .cn-toc { + color: #d7a55b; + font-size: 2.5em; + padding: 0.2em; + background-color: rgba(0, 0, 0, 0.1); + + .app--dark & { + background-color: rgba(0, 0, 0, 0.3); + } + } +} + +.markdown-highlighting--inline { + .h1, + .h11, + .h2, + .h22, + .h3, + .h4, + .h5, + .h6, + .cl-toc { + font-size: inherit; + } +} diff --git a/src/styles/prism.scss b/src/styles/prism.scss new file mode 100644 index 0000000..e9332d1 --- /dev/null +++ b/src/styles/prism.scss @@ -0,0 +1,73 @@ +.token.pre.gfm, +.prism { + * { + font-weight: inherit !important; + } + + .token.comment, + .token.prolog, + .token.doctype, + .token.cdata { + color: #708090; + } + + .token.punctuation { + color: #999; + } + + .namespace { + opacity: 0.7; + } + + .token.property, + .token.tag, + .token.boolean, + .token.number, + .token.constant, + .token.symbol, + .token.deleted { + color: #905; + } + + .token.selector, + .token.attr-name, + .token.string, + .token.char, + .token.builtin, + .token.inserted { + color: #690; + } + + .token.operator, + .token.entity, + .token.url, + .language-css .token.string, + .style .token.string { + color: #a67f59; + } + + .token.atrule, + .token.attr-value, + .token.keyword { + color: #07a; + } + + .token.function { + color: #dd4a68; + } + + .token.regex, + .token.important, + .token.variable { + color: #e90; + } + + .token.important, + .token.bold { + font-weight: 500; + } + + .token.italic { + font-style: italic; + } +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..ed90996 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,38 @@ +$font-family-main: Lato, 'Helvetica Neue', Helvetica, sans-serif; +$font-family-monospace: 'Roboto Mono', 'Lucida Sans Typewriter', 'Lucida Console', monaco, Courrier, monospace; +$body-color-light: rgba(0, 0, 0, 0.75); +$body-color-dark: rgba(255, 255, 255, 0.75); +$code-bg: rgba(0, 0, 0, 0.05); +$code-dark-bg: #333; +$line-height-base: 1.67; +$line-height-title: 1.33; +$font-size-monospace: 0.85em; +$highlighting-color: #ff0; +$dark-highlighting-color: rgba(255, 255, 0, 0.6); +$selection-highlighting-color: #ffb067; +$info-bg: #ffad3326; +$code-border-radius: 3px; +$link-color: #0c93e4; +$error-color: #f31; +$border-radius-base: 3px; +$hr-color: rgba(128, 128, 128, 0.33); +$navbar-bg: #2c2c2c; +$navbar-color: mix($navbar-bg, #fff, 33%); +$navbar-hover-color: #fff; +$navbar-hover-background: rgba(255, 255, 255, 0.1); + +$editor-background-light: #fff; +$editor-background-dark: #36312c; + +$editor-color-light: rgba(0, 0, 0, 0.8); +$editor-color-light-low: rgba(0, 0, 0, 0.75); +$editor-color-light-high: rgba(0, 0, 0, 0.28); +$editor-color-light-blockquote: rgba(0, 0, 0, 0.48); + +$editor-color-dark: rgba(255, 255, 255, 0.8); +$editor-color-dark-low: rgba(255, 255, 255, 0.75); +$editor-color-dark-high: rgba(255, 255, 255, 0.28); +$editor-color-dark-blockquote: rgba(255, 255, 255, 0.48); + +$editor-font-weight-base: 400; +$editor-font-weight-bold: 600; diff --git a/static/landing/abc.png b/static/landing/abc.png new file mode 100644 index 0000000..da05fc7 Binary files /dev/null and b/static/landing/abc.png differ diff --git a/static/landing/discussion.png b/static/landing/discussion.png new file mode 100644 index 0000000..b8a0189 Binary files /dev/null and b/static/landing/discussion.png differ diff --git a/static/landing/favicon.ico b/static/landing/favicon.ico new file mode 100644 index 0000000..1704ee8 Binary files /dev/null and b/static/landing/favicon.ico differ diff --git a/static/landing/gfm.png b/static/landing/gfm.png new file mode 100644 index 0000000..1ddf4a8 Binary files /dev/null and b/static/landing/gfm.png differ diff --git a/static/landing/gistshare.html b/static/landing/gistshare.html new file mode 100644 index 0000000..b1e6d1b --- /dev/null +++ b/static/landing/gistshare.html @@ -0,0 +1,165 @@ + + + +文章分享 - StackEdit中文版 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/landing/index.html b/static/landing/index.html new file mode 100644 index 0000000..2277978 --- /dev/null +++ b/static/landing/index.html @@ -0,0 +1,539 @@ + + + + +StackEdit中文版 – 浏览器内 Markdown 编辑器 & 笔记利器 + + + + + + + + + + + + + + + +++ + + + + + + diff --git a/static/landing/katex.gif b/static/landing/katex.gif new file mode 100644 index 0000000..216677d Binary files /dev/null and b/static/landing/katex.gif differ diff --git a/static/landing/logo.svg b/static/landing/logo.svg new file mode 100644 index 0000000..5611016 --- /dev/null +++ b/static/landing/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/landing/mermaid.gif b/static/landing/mermaid.gif new file mode 100644 index 0000000..8dcab22 Binary files /dev/null and b/static/landing/mermaid.gif differ diff --git a/static/landing/navigation-bar.png b/static/landing/navigation-bar.png new file mode 100644 index 0000000..6e81e8d Binary files /dev/null and b/static/landing/navigation-bar.png differ diff --git a/static/landing/privacy_policy.html b/static/landing/privacy_policy.html new file mode 100644 index 0000000..0525366 --- /dev/null +++ b/static/landing/privacy_policy.html @@ -0,0 +1,96 @@ + + + + + + + + + + + +++ +++ ++ 浏览器内 Markdown 笔记利器 + ++ +++ +无与伦比的写作体验
+++++++丰富的 Markdown 编辑器
+StackEdit中文版 的 Markdown 语法高亮是独一无二的。 编辑器的精致文本格式可帮助您可视化文件的最终呈现。
++++++
+++
++所见即所得控件
+StackEdit中文版 提供了非常方便的格式化按钮和快捷方式,这要归功于 Stack Overflow 使用的所见即所得式 Markdown 编辑器 PageDown。
+++++++智能布局
+无论你是写作、阅读还是评论……StackEdit中文版的布局都为你提供了所需的灵活性。
++++
++++滚动同步实时预览
+StackEdit中文版的滚动同步功能精确地绑定了编辑器面板和预览面板的滚动条,以确保您在编写时始终关注输出。
++
专为网络写手设计
+++++++保持同步
+StackEdit中文版 可以将您的文件与 Gitee、GitHub、Google Drive 和 Dropbox 同步。 它还可以将它们作为博客文章发布到 Blogger、WordPress 和 Zendesk。 您可以选择是以 Markdown 格式、HTML 上传,还是使用 Handlebars 模板引擎格式化输出。
++++
++++++协作
+借助 StackEdit中文版,您可以共享协作文档空间,这要归功于同步机制。 如果两个协作者同时处理同一个文件,StackEdit中文版 会负责合并更改。
++
++++评论
+StackEdit中文版 允许您在文件中插入内联评论和嵌入协作者讨论,就像 Microsoft Word 和 Google Docs 一样。
++
++++离线写作!
+即使在旅行时,StackEdit中文版 仍然可以访问,让您可以像任何桌面应用程序一样离线编写。 你没有借口再偷懒!
+扩展的 Markdown 支持
+++++
++++
++++GitHub 风格的 Markdown
+StackEdit中文版 支持不同的 Markdown 风格,例如 Markdown Extra、GFM 和 CommonMark。 每个 Markdown 功能都可以在您方便的时候启用或禁用。
+++++
++++
++++LaTeX 数学表达式
+StackEdit中文版 从您的 Markdown 文件中的 LaTeX 表达式呈现数学公式。
++++++++
++++UML 图
+StackEdit中文版 使您能够使用简单的语法编写序列图和流程图。
++++++++
++++乐谱
+StackEdit中文版 可以使用 ABC 表示法渲染乐谱。
++++++++
++++Emojis表情
+StackEdit中文版 支持使用 Markdown 表情符号标记在文件中插入表情符号。
+隐私权政策 + + + +隐私权政策
++ 【StackEdit中文版】(以下简称“此站”)深知个人信息对您的重要性, 故不会特意收集个人信息。 +++ 请在使用我们的产品(或服务)前,仔细阅读并了解本《隐私权政策》。 +++ 一、关于您的文件信息 +++ 个人文档都是存储在第三方,此站对所有第三方的文件都是在您授权之后,通过您的浏览器直接访问,并不会在此站后端获取和保存您的任何个人文件信息。 +++ 二、关于您的用户信息 +++ 本站不存在注册行为,待您授权后,您在第三方平台上个人信息的获取仅仅是在您的浏览器中直接获取,并不会在此站后端获取和保存您的个人信息。 +++ 三、其他 +++ 我们可能会更新本隐私政策,以反映我们的业务需求和适用法律的变化。在更新隐私政策时,我们会通过我们的网站或其他合适的方式通知您。如果您继续使用我们的服务,则意味着您同意更新后的隐私政策。 +++ 四、如何联系我们 +++ 如果您对本隐私政策有任何疑问、意见或建议,通过以下方式与我们联系: +++ 邮箱:【mafgwo@163.com】 +++ 微信:【qicoding】 +++ 一般情况下,我们将在一周内回复。 ++ + \ No newline at end of file diff --git a/static/landing/providers.png b/static/landing/providers.png new file mode 100644 index 0000000..309ef18 Binary files /dev/null and b/static/landing/providers.png differ diff --git a/static/landing/scroll-sync.gif b/static/landing/scroll-sync.gif new file mode 100644 index 0000000..93f8191 Binary files /dev/null and b/static/landing/scroll-sync.gif differ diff --git a/static/landing/share.html b/static/landing/share.html new file mode 100644 index 0000000..9c4deb1 --- /dev/null +++ b/static/landing/share.html @@ -0,0 +1,177 @@ + + + +文章分享 - StackEdit中文版 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/landing/smart-layout.png b/static/landing/smart-layout.png new file mode 100644 index 0000000..9475214 Binary files /dev/null and b/static/landing/smart-layout.png differ diff --git a/static/landing/syntax-highlighting.gif b/static/landing/syntax-highlighting.gif new file mode 100644 index 0000000..bf967b5 Binary files /dev/null and b/static/landing/syntax-highlighting.gif differ diff --git a/static/landing/twemoji.png b/static/landing/twemoji.png new file mode 100644 index 0000000..a3919a8 Binary files /dev/null and b/static/landing/twemoji.png differ diff --git a/static/landing/workspace.png b/static/landing/workspace.png new file mode 100644 index 0000000..3a863b2 Binary files /dev/null and b/static/landing/workspace.png differ diff --git a/static/oauth2/callback.html b/static/oauth2/callback.html new file mode 100644 index 0000000..0778358 --- /dev/null +++ b/static/oauth2/callback.html @@ -0,0 +1,9 @@ + + + + + + diff --git a/static/sitemap.xml b/static/sitemap.xml new file mode 100644 index 0000000..4526336 --- /dev/null +++ b/static/sitemap.xml @@ -0,0 +1,23 @@ + ++ diff --git a/static/themes/edit-theme-azure.js b/static/themes/edit-theme-azure.js new file mode 100644 index 0000000..0d9c6c0 --- /dev/null +++ b/static/themes/edit-theme-azure.js @@ -0,0 +1,72 @@ +function init_edit_theme_azure() { + const style = document.createElement('style'); + style.id = 'edit-theme-azure'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--azure .editor__inner {\n\ + color: #fff;\n\ + caret-color: #fff;\n\ +}\n\ +.edit-theme--azure .editor {\n\ + background-color: #181D26;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--azure .editor__inner .cn-head,\n\ +.edit-theme--azure .editor-in-page-buttons .icon {\n\ + color: #64aeb3;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--azure .editor__inner .cn-strong {\n\ + color: #508aaa;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--azure .editor__inner .blockquote {\n\ + color: #52708b;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--azure .editor__inner .cl,\n\ +.edit-theme--azure .editor__inner .hr,\n\ +.edit-theme--azure .editor__inner .link,\n\ +.edit-theme--azure .editor__inner .linkref, \n\ +.edit-theme--azure .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--azure .editor__inner .cn-toc, \n\ +.edit-theme--azure .editor__inner .code,\n\ +.edit-theme--azure .editor__inner .img,\n\ +.edit-theme--azure .editor__inner .img-wrapper,\n\ +.edit-theme--azure .editor__inner .imgref,\n\ +.edit-theme--azure .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--azure .editor__inner .cn-code {\n\ + color: #6AB0A3;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--azure .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--azure .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--azure .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--azure .editor__inner .linkref .cl-underlined-text {\n\ + color: #64aeb3;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--azure .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--azure .editor__inner .keyword {\n\ + color: #508aaa;\n\ +}\n\ +.edit-theme--azure .editor__inner .email,\n\ +.edit-theme--azure .editor__inner .cl-title,\n\ +.edit-theme--azure .editor__inner .tag,\n\ +.edit-theme--azure .editor__inner .latex,\n\ +.edit-theme--azure .editor__inner .math,\n\ +.edit-theme--azure .editor__inner .entity,\n\ +.edit-theme--azure .editor__inner .pre [class*='language-'] {\n\ + color: #fff;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_azure(); \ No newline at end of file diff --git a/static/themes/edit-theme-carbonight.js b/static/themes/edit-theme-carbonight.js new file mode 100644 index 0000000..e670a1c --- /dev/null +++ b/static/themes/edit-theme-carbonight.js @@ -0,0 +1,72 @@ +function init_edit_theme_carbonight() { + const style = document.createElement('style'); + style.id = 'edit-theme-carbonight'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--carbonight .editor__inner {\n\ + color: #B0B0B0;\n\ + caret-color: #B0B0B0;\n\ +}\n\ +.edit-theme--carbonight .editor {\n\ + background-color: #2E2C2B;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--carbonight .editor__inner .cn-head,\n\ +.edit-theme--carbonight .editor-in-page-buttons .icon {\n\ + color: #B0B0B0;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--carbonight .editor__inner .cn-strong {\n\ + color: #eeeeee;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--carbonight .editor__inner .blockquote {\n\ + color: #8C8C8C;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--carbonight .editor__inner .cl,\n\ +.edit-theme--carbonight .editor__inner .hr,\n\ +.edit-theme--carbonight .editor__inner .link,\n\ +.edit-theme--carbonight .editor__inner .linkref, \n\ +.edit-theme--carbonight .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--carbonight .editor__inner .cn-toc, \n\ +.edit-theme--carbonight .editor__inner .code,\n\ +.edit-theme--carbonight .editor__inner .img,\n\ +.edit-theme--carbonight .editor__inner .img-wrapper,\n\ +.edit-theme--carbonight .editor__inner .imgref,\n\ +.edit-theme--carbonight .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--carbonight .editor__inner .cn-code {\n\ + color: #fff;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--carbonight .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--carbonight .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--carbonight .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--carbonight .editor__inner .linkref .cl-underlined-text {\n\ + color: #fff;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--carbonight .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--carbonight .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--carbonight .editor__inner .email,\n\ +.edit-theme--carbonight .editor__inner .cl-title,\n\ +.edit-theme--carbonight .editor__inner .tag,\n\ +.edit-theme--carbonight .editor__inner .latex,\n\ +.edit-theme--carbonight .editor__inner .math,\n\ +.edit-theme--carbonight .editor__inner .entity,\n\ +.edit-theme--carbonight .editor__inner .pre [class*='language-'] {\n\ + color: #B0B0B0;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_carbonight(); \ No newline at end of file diff --git a/static/themes/edit-theme-clouds.js b/static/themes/edit-theme-clouds.js new file mode 100644 index 0000000..12ccb8d --- /dev/null +++ b/static/themes/edit-theme-clouds.js @@ -0,0 +1,72 @@ +function init_edit_theme_clouds() { + const style = document.createElement('style'); + style.id = 'edit-theme-clouds'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--clouds .editor__inner {\n\ + color: #000;\n\ + caret-color: #000;\n\ +}\n\ +.edit-theme--clouds .editor {\n\ + background-color: #fff;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--clouds .editor__inner .cn-head,\n\ +.edit-theme--clouds .editor-in-page-buttons .icon {\n\ + color: #46A609;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--clouds .editor__inner .cn-strong {\n\ + color: #AF956F;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--clouds .editor__inner .blockquote {\n\ + color: #5D90CD;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--clouds .editor__inner .cl,\n\ +.edit-theme--clouds .editor__inner .hr,\n\ +.edit-theme--clouds .editor__inner .link,\n\ +.edit-theme--clouds .editor__inner .linkref, \n\ +.edit-theme--clouds .editor__inner .linkdef .url {\n\ + color: rgba(102,128,153,0.6);\n\ +}\n\ +.edit-theme--clouds .editor__inner .cn-toc, \n\ +.edit-theme--clouds .editor__inner .code,\n\ +.edit-theme--clouds .editor__inner .img,\n\ +.edit-theme--clouds .editor__inner .img-wrapper,\n\ +.edit-theme--clouds .editor__inner .imgref,\n\ +.edit-theme--clouds .editor__inner .cl-toc {\n\ + color: rgba(102,128,153,0.6);\n\ + background-color: rgba(102,128,153,0.075);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--clouds .editor__inner .cn-code {\n\ + color: #C52727;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--clouds .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--clouds .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--clouds .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--clouds .editor__inner .linkref .cl-underlined-text {\n\ + color: #5D90CD;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--clouds .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--clouds .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--clouds .editor__inner .email,\n\ +.edit-theme--clouds .editor__inner .cl-title,\n\ +.edit-theme--clouds .editor__inner .tag,\n\ +.edit-theme--clouds .editor__inner .latex,\n\ +.edit-theme--clouds .editor__inner .math,\n\ +.edit-theme--clouds .editor__inner .entity,\n\ +.edit-theme--clouds .editor__inner .pre [class*='language-'] {\n\ + color: #000;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_clouds(); \ No newline at end of file diff --git a/static/themes/edit-theme-clouds_midnight.js b/static/themes/edit-theme-clouds_midnight.js new file mode 100644 index 0000000..3cade63 --- /dev/null +++ b/static/themes/edit-theme-clouds_midnight.js @@ -0,0 +1,72 @@ +function init_edit_theme_clouds_midnight() { + const style = document.createElement('style'); + style.id = 'edit-theme-clouds_midnight'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--clouds_midnight .editor__inner {\n\ + color: #929292;\n\ + caret-color: #929292;\n\ +}\n\ +.edit-theme--clouds_midnight .editor {\n\ + background-color: #191919;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .cn-head,\n\ +.edit-theme--clouds_midnight .editor-in-page-buttons .icon {\n\ + color: #46A609;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .cn-strong {\n\ + color: #927C5D;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .blockquote {\n\ + color: #5D90CD;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .cl,\n\ +.edit-theme--clouds_midnight .editor__inner .hr,\n\ +.edit-theme--clouds_midnight .editor__inner .link,\n\ +.edit-theme--clouds_midnight .editor__inner .linkref, \n\ +.edit-theme--clouds_midnight .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--clouds_midnight .editor__inner .cn-toc, \n\ +.edit-theme--clouds_midnight .editor__inner .code,\n\ +.edit-theme--clouds_midnight .editor__inner .img,\n\ +.edit-theme--clouds_midnight .editor__inner .img-wrapper,\n\ +.edit-theme--clouds_midnight .editor__inner .imgref,\n\ +.edit-theme--clouds_midnight .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .cn-code {\n\ + color: #E92E2E;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--clouds_midnight .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--clouds_midnight .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--clouds_midnight .editor__inner .linkref .cl-underlined-text {\n\ + color: #5D90CD;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--clouds_midnight .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--clouds_midnight .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--clouds_midnight .editor__inner .email,\n\ +.edit-theme--clouds_midnight .editor__inner .cl-title,\n\ +.edit-theme--clouds_midnight .editor__inner .tag,\n\ +.edit-theme--clouds_midnight .editor__inner .latex,\n\ +.edit-theme--clouds_midnight .editor__inner .math,\n\ +.edit-theme--clouds_midnight .editor__inner .entity,\n\ +.edit-theme--clouds_midnight .editor__inner .pre [class*='language-'] {\n\ + color: #929292;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_clouds_midnight(); \ No newline at end of file diff --git a/static/themes/edit-theme-dawn.js b/static/themes/edit-theme-dawn.js new file mode 100644 index 0000000..02e3d0d --- /dev/null +++ b/static/themes/edit-theme-dawn.js @@ -0,0 +1,72 @@ +function init_edit_theme_dawn() { + const style = document.createElement('style'); + style.id = 'edit-theme-dawn'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--dawn .editor__inner {\n\ + color: #080808;\n\ + caret-color: #080808;\n\ +}\n\ +.edit-theme--dawn .editor {\n\ + background-color: #F9F9F9;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--dawn .editor__inner .cn-head,\n\ +.edit-theme--dawn .editor-in-page-buttons .icon {\n\ + color: #19356D;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--dawn .editor__inner .cn-strong {\n\ + color: #794938;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--dawn .editor__inner .blockquote {\n\ + color: #811F24;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--dawn .editor__inner .cl,\n\ +.edit-theme--dawn .editor__inner .hr,\n\ +.edit-theme--dawn .editor__inner .link,\n\ +.edit-theme--dawn .editor__inner .linkref, \n\ +.edit-theme--dawn .editor__inner .linkdef .url {\n\ + color: rgba(102,128,153,0.6);\n\ +}\n\ +.edit-theme--dawn .editor__inner .cn-toc, \n\ +.edit-theme--dawn .editor__inner .code,\n\ +.edit-theme--dawn .editor__inner .img,\n\ +.edit-theme--dawn .editor__inner .img-wrapper,\n\ +.edit-theme--dawn .editor__inner .imgref,\n\ +.edit-theme--dawn .editor__inner .cl-toc {\n\ + color: rgba(102,128,153,0.6);\n\ + background-color: rgba(102,128,153,0.075);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--dawn .editor__inner .cn-code {\n\ + color: #693A17;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--dawn .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--dawn .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--dawn .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--dawn .editor__inner .linkref .cl-underlined-text {\n\ + color: #0B6125;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--dawn .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--dawn .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--dawn .editor__inner .email,\n\ +.edit-theme--dawn .editor__inner .cl-title,\n\ +.edit-theme--dawn .editor__inner .tag,\n\ +.edit-theme--dawn .editor__inner .latex,\n\ +.edit-theme--dawn .editor__inner .math,\n\ +.edit-theme--dawn .editor__inner .entity,\n\ +.edit-theme--dawn .editor__inner .pre [class*='language-'] {\n\ + color: #080808;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_dawn(); \ No newline at end of file diff --git a/static/themes/edit-theme-espresso_libre.js b/static/themes/edit-theme-espresso_libre.js new file mode 100644 index 0000000..8b556c1 --- /dev/null +++ b/static/themes/edit-theme-espresso_libre.js @@ -0,0 +1,72 @@ +function init_edit_theme_espresso_libre() { + const style = document.createElement('style'); + style.id = 'edit-theme-espresso_libre'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--espresso_libre .editor__inner {\n\ + color: #BDAE9D;\n\ + caret-color: #BDAE9D;\n\ +}\n\ +.edit-theme--espresso_libre .editor {\n\ + background-color: #2A211C;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .cn-head,\n\ +.edit-theme--espresso_libre .editor-in-page-buttons .icon {\n\ + color: #44AA43;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .cn-strong {\n\ + color: #43A8ED;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .blockquote {\n\ + color: #52708b;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .cl,\n\ +.edit-theme--espresso_libre .editor__inner .hr,\n\ +.edit-theme--espresso_libre .editor__inner .link,\n\ +.edit-theme--espresso_libre .editor__inner .linkref, \n\ +.edit-theme--espresso_libre .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--espresso_libre .editor__inner .cn-toc, \n\ +.edit-theme--espresso_libre .editor__inner .code,\n\ +.edit-theme--espresso_libre .editor__inner .img,\n\ +.edit-theme--espresso_libre .editor__inner .img-wrapper,\n\ +.edit-theme--espresso_libre .editor__inner .imgref,\n\ +.edit-theme--espresso_libre .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .cn-code {\n\ + color: #7290D9;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--espresso_libre .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--espresso_libre .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--espresso_libre .editor__inner .linkref .cl-underlined-text {\n\ + color: #049B0A;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--espresso_libre .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--espresso_libre .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--espresso_libre .editor__inner .email,\n\ +.edit-theme--espresso_libre .editor__inner .cl-title,\n\ +.edit-theme--espresso_libre .editor__inner .tag,\n\ +.edit-theme--espresso_libre .editor__inner .latex,\n\ +.edit-theme--espresso_libre .editor__inner .math,\n\ +.edit-theme--espresso_libre .editor__inner .entity,\n\ +.edit-theme--espresso_libre .editor__inner .pre [class*='language-'] {\n\ + color: #BDAE9D;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_espresso_libre(); \ No newline at end of file diff --git a/static/themes/edit-theme-github.js b/static/themes/edit-theme-github.js new file mode 100644 index 0000000..a78e025 --- /dev/null +++ b/static/themes/edit-theme-github.js @@ -0,0 +1,73 @@ +function init_edit_theme_github() { + const style = document.createElement('style'); + style.id = 'edit-theme-github'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--github .editor__inner {\n\ + color: #000;\n\ + caret-color: #000;\n\ + background-color: #fff;\n\ +}\n\ +.edit-theme--github .editor {\n\ + background-color: #fff;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--github .editor__inner .cn-head,\n\ +.edit-theme--github .editor-in-page-buttons .icon {\n\ + color: #AAAAAA;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--github .editor__inner .cn-strong {\n\ + color: #000;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--github .editor__inner .blockquote {\n\ + color: rgba(0,0,0,0.48);\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--github .editor__inner .cl,\n\ +.edit-theme--github .editor__inner .hr,\n\ +.edit-theme--github .editor__inner .link,\n\ +.edit-theme--github .editor__inner .linkref, \n\ +.edit-theme--github .editor__inner .linkdef .url {\n\ + color: rgba(0,0,0,0.28);\n\ +}\n\ +.edit-theme--github .editor__inner .cn-toc, \n\ +.edit-theme--github .editor__inner .code,\n\ +.edit-theme--github .editor__inner .img,\n\ +.edit-theme--github .editor__inner .img-wrapper,\n\ +.edit-theme--github .editor__inner .imgref,\n\ +.edit-theme--github .editor__inner .cl-toc {\n\ + color: rgba(0,0,0,0.28);\n\ + background-color: rgba(102,128,153,0.075);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--github .editor__inner .cn-code {\n\ + color: #0086B3;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--github .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--github .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--github .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--github .editor__inner .linkref .cl-underlined-text {\n\ + color: #D14;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--github .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--github .editor__inner .keyword {\n\ + color: rgba(0,0,0,0.75);\n\ +}\n\ +.edit-theme--github .editor__inner .email,\n\ +.edit-theme--github .editor__inner .cl-title,\n\ +.edit-theme--github .editor__inner .tag,\n\ +.edit-theme--github .editor__inner .latex,\n\ +.edit-theme--github .editor__inner .math,\n\ +.edit-theme--github .editor__inner .entity,\n\ +.edit-theme--github .editor__inner .pre [class*='language-'] {\n\ + color: #29333d;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_github(); \ No newline at end of file diff --git a/static/themes/edit-theme-iceberg_contrast.js b/static/themes/edit-theme-iceberg_contrast.js new file mode 100644 index 0000000..12e44ba --- /dev/null +++ b/static/themes/edit-theme-iceberg_contrast.js @@ -0,0 +1,72 @@ +function init_edit_theme_iceberg_contrast() { + const style = document.createElement('style'); + style.id = 'edit-theme-iceberg_contrast'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--iceberg_contrast .editor__inner {\n\ + color: #BDD6DB;\n\ + caret-color: #fff;\n\ +}\n\ +.edit-theme--iceberg_contrast .editor {\n\ + background-color: #0b0e0e;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .cn-head,\n\ +.edit-theme--iceberg_contrast .editor-in-page-buttons .icon {\n\ + color: #fff;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .cn-strong {\n\ + color: #B1E2F2;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .blockquote {\n\ + color: #ffffff;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .cl,\n\ +.edit-theme--iceberg_contrast .editor__inner .hr,\n\ +.edit-theme--iceberg_contrast .editor__inner .link,\n\ +.edit-theme--iceberg_contrast .editor__inner .linkref, \n\ +.edit-theme--iceberg_contrast .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--iceberg_contrast .editor__inner .cn-toc, \n\ +.edit-theme--iceberg_contrast .editor__inner .code,\n\ +.edit-theme--iceberg_contrast .editor__inner .img,\n\ +.edit-theme--iceberg_contrast .editor__inner .img-wrapper,\n\ +.edit-theme--iceberg_contrast .editor__inner .imgref,\n\ +.edit-theme--iceberg_contrast .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .cn-code {\n\ + color: #fff;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--iceberg_contrast .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--iceberg_contrast .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--iceberg_contrast .editor__inner .linkref .cl-underlined-text {\n\ + color: #fff;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--iceberg_contrast .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--iceberg_contrast .editor__inner .keyword {\n\ + color: #fff;\n\ +}\n\ +.edit-theme--iceberg_contrast .editor__inner .email,\n\ +.edit-theme--iceberg_contrast .editor__inner .cl-title,\n\ +.edit-theme--iceberg_contrast .editor__inner .tag,\n\ +.edit-theme--iceberg_contrast .editor__inner .latex,\n\ +.edit-theme--iceberg_contrast .editor__inner .math,\n\ +.edit-theme--iceberg_contrast .editor__inner .entity,\n\ +.edit-theme--iceberg_contrast .editor__inner .pre [class*='language-'] {\n\ + color: #BDD6DB;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_iceberg_contrast(); \ No newline at end of file diff --git a/static/themes/edit-theme-lavender.js b/static/themes/edit-theme-lavender.js new file mode 100644 index 0000000..0ea85fd --- /dev/null +++ b/static/themes/edit-theme-lavender.js @@ -0,0 +1,72 @@ +function init_edit_theme_lavender() { + const style = document.createElement('style'); + style.id = 'edit-theme-lavender'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--lavender .editor__inner {\n\ + color: #E0CEED;\n\ + caret-color: #E0CEED;\n\ +}\n\ +.edit-theme--lavender .editor {\n\ + background-color: #29222E;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--lavender .editor__inner .cn-head,\n\ +.edit-theme--lavender .editor-in-page-buttons .icon {\n\ + color: #F25AE6;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--lavender .editor__inner .cn-strong {\n\ + color: #8E6DA6;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--lavender .editor__inner .blockquote {\n\ + color: #B657FF;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--lavender .editor__inner .cl,\n\ +.edit-theme--lavender .editor__inner .hr,\n\ +.edit-theme--lavender .editor__inner .link,\n\ +.edit-theme--lavender .editor__inner .linkref, \n\ +.edit-theme--lavender .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--lavender .editor__inner .cn-toc, \n\ +.edit-theme--lavender .editor__inner .code,\n\ +.edit-theme--lavender .editor__inner .img,\n\ +.edit-theme--lavender .editor__inner .img-wrapper,\n\ +.edit-theme--lavender .editor__inner .imgref,\n\ +.edit-theme--lavender .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--lavender .editor__inner .cn-code {\n\ + color: #8E69C9;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--lavender .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--lavender .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--lavender .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--lavender .editor__inner .linkref .cl-underlined-text {\n\ + color: #F5B0EF;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--lavender .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--lavender .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--lavender .editor__inner .email,\n\ +.edit-theme--lavender .editor__inner .cl-title,\n\ +.edit-theme--lavender .editor__inner .tag,\n\ +.edit-theme--lavender .editor__inner .latex,\n\ +.edit-theme--lavender .editor__inner .math,\n\ +.edit-theme--lavender .editor__inner .entity,\n\ +.edit-theme--lavender .editor__inner .pre [class*='language-'] {\n\ + color: #E0CEED;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_lavender(); \ No newline at end of file diff --git a/static/themes/edit-theme-mintchoc.js b/static/themes/edit-theme-mintchoc.js new file mode 100644 index 0000000..05795dc --- /dev/null +++ b/static/themes/edit-theme-mintchoc.js @@ -0,0 +1,72 @@ +function init_edit_theme_mintchoc() { + const style = document.createElement('style'); + style.id = 'edit-theme-mintchoc'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--mintchoc .editor__inner {\n\ + color: #BABABA;\n\ + caret-color: #BABABA;\n\ +}\n\ +.edit-theme--mintchoc .editor {\n\ + background-color: #2b221c;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--mintchoc .editor__inner .cn-head,\n\ +.edit-theme--mintchoc .editor-in-page-buttons .icon {\n\ + color: #00E08C;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--mintchoc .editor__inner .cn-strong {\n\ + color: #9D8262;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--mintchoc .editor__inner .blockquote {\n\ + color: #008D62;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--mintchoc .editor__inner .cl,\n\ +.edit-theme--mintchoc .editor__inner .hr,\n\ +.edit-theme--mintchoc .editor__inner .link,\n\ +.edit-theme--mintchoc .editor__inner .linkref, \n\ +.edit-theme--mintchoc .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--mintchoc .editor__inner .cn-toc, \n\ +.edit-theme--mintchoc .editor__inner .code,\n\ +.edit-theme--mintchoc .editor__inner .img,\n\ +.edit-theme--mintchoc .editor__inner .img-wrapper,\n\ +.edit-theme--mintchoc .editor__inner .imgref,\n\ +.edit-theme--mintchoc .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--mintchoc .editor__inner .cn-code {\n\ + color: #008D62;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--mintchoc .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--mintchoc .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--mintchoc .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--mintchoc .editor__inner .linkref .cl-underlined-text {\n\ + color: #00E08C;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--mintchoc .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--mintchoc .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--mintchoc .editor__inner .email,\n\ +.edit-theme--mintchoc .editor__inner .cl-title,\n\ +.edit-theme--mintchoc .editor__inner .tag,\n\ +.edit-theme--mintchoc .editor__inner .latex,\n\ +.edit-theme--mintchoc .editor__inner .math,\n\ +.edit-theme--mintchoc .editor__inner .entity,\n\ +.edit-theme--mintchoc .editor__inner .pre [class*='language-'] {\n\ + color: #BABABA;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_mintchoc(); \ No newline at end of file diff --git a/static/themes/edit-theme-peacock.js b/static/themes/edit-theme-peacock.js new file mode 100644 index 0000000..02054ec --- /dev/null +++ b/static/themes/edit-theme-peacock.js @@ -0,0 +1,72 @@ +function init_edit_theme_peacock() { + const style = document.createElement('style'); + style.id = 'edit-theme-peacock'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--peacock .editor__inner {\n\ + color: #ede0ce;\n\ + caret-color: #ede0ce;\n\ +}\n\ +.edit-theme--peacock .editor {\n\ + background-color: #2b2a27;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--peacock .editor__inner .cn-head,\n\ +.edit-theme--peacock .editor-in-page-buttons .icon {\n\ + color: #bcd42a;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--peacock .editor__inner .cn-strong {\n\ + color: #26A6A6;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--peacock .editor__inner .blockquote {\n\ + color: #ff5d38;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--peacock .editor__inner .cl,\n\ +.edit-theme--peacock .editor__inner .hr,\n\ +.edit-theme--peacock .editor__inner .link,\n\ +.edit-theme--peacock .editor__inner .linkref, \n\ +.edit-theme--peacock .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--peacock .editor__inner .cn-toc, \n\ +.edit-theme--peacock .editor__inner .code,\n\ +.edit-theme--peacock .editor__inner .img,\n\ +.edit-theme--peacock .editor__inner .img-wrapper,\n\ +.edit-theme--peacock .editor__inner .imgref,\n\ +.edit-theme--peacock .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--peacock .editor__inner .cn-code {\n\ + color: #FF5D38;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--peacock .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--peacock .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--peacock .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--peacock .editor__inner .linkref .cl-underlined-text {\n\ + color: #bcd42a;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--peacock .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--peacock .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--peacock .editor__inner .email,\n\ +.edit-theme--peacock .editor__inner .cl-title,\n\ +.edit-theme--peacock .editor__inner .tag,\n\ +.edit-theme--peacock .editor__inner .latex,\n\ +.edit-theme--peacock .editor__inner .math,\n\ +.edit-theme--peacock .editor__inner .entity,\n\ +.edit-theme--peacock .editor__inner .pre [class*='language-'] {\n\ + color: #ede0ce;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_peacock(); \ No newline at end of file diff --git a/static/themes/edit-theme-slate.js b/static/themes/edit-theme-slate.js new file mode 100644 index 0000000..cde34d4 --- /dev/null +++ b/static/themes/edit-theme-slate.js @@ -0,0 +1,72 @@ +function init_edit_theme_slate() { + const style = document.createElement('style'); + style.id = 'edit-theme-slate'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--slate .editor__inner {\n\ + color: #ebebf4;\n\ + caret-color: #ebebf4;\n\ +}\n\ +.edit-theme--slate .editor {\n\ + background-color: #19191f;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--slate .editor__inner .cn-head,\n\ +.edit-theme--slate .editor-in-page-buttons .icon {\n\ + color: #9eb2d9;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--slate .editor__inner .cn-strong {\n\ + color: #566981;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--slate .editor__inner .blockquote {\n\ + color: #89A7B1;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--slate .editor__inner .cl,\n\ +.edit-theme--slate .editor__inner .hr,\n\ +.edit-theme--slate .editor__inner .link,\n\ +.edit-theme--slate .editor__inner .linkref, \n\ +.edit-theme--slate .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--slate .editor__inner .cn-toc, \n\ +.edit-theme--slate .editor__inner .code,\n\ +.edit-theme--slate .editor__inner .img,\n\ +.edit-theme--slate .editor__inner .img-wrapper,\n\ +.edit-theme--slate .editor__inner .imgref,\n\ +.edit-theme--slate .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--slate .editor__inner .cn-code {\n\ + color: #89A7B1;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--slate .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--slate .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--slate .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--slate .editor__inner .linkref .cl-underlined-text {\n\ + color: #9eb2d9;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--slate .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--slate .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--slate .editor__inner .email,\n\ +.edit-theme--slate .editor__inner .cl-title,\n\ +.edit-theme--slate .editor__inner .tag,\n\ +.edit-theme--slate .editor__inner .latex,\n\ +.edit-theme--slate .editor__inner .math,\n\ +.edit-theme--slate .editor__inner .entity,\n\ +.edit-theme--slate .editor__inner .pre [class*='language-'] {\n\ + color: #ebebf4;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_slate(); \ No newline at end of file diff --git a/static/themes/edit-theme-solarflare.js b/static/themes/edit-theme-solarflare.js new file mode 100644 index 0000000..e9a6d44 --- /dev/null +++ b/static/themes/edit-theme-solarflare.js @@ -0,0 +1,72 @@ +function init_edit_theme_solarflare() { + const style = document.createElement('style'); + style.id = 'edit-theme-solarflare'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--solarflare .editor__inner {\n\ + color: #e3e2e0;\n\ + caret-color: #e3e2e0;\n\ +}\n\ +.edit-theme--solarflare .editor {\n\ + background-color: #292D30;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--solarflare .editor__inner .cn-head,\n\ +.edit-theme--solarflare .editor-in-page-buttons .icon {\n\ + color: #FF4E50;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--solarflare .editor__inner .cn-strong {\n\ + color: #FF4E50;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--solarflare .editor__inner .blockquote {\n\ + color: #FF4E50;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--solarflare .editor__inner .cl,\n\ +.edit-theme--solarflare .editor__inner .hr,\n\ +.edit-theme--solarflare .editor__inner .link,\n\ +.edit-theme--solarflare .editor__inner .linkref, \n\ +.edit-theme--solarflare .editor__inner .linkdef .url {\n\ + color: rgba(139,158,177,0.8);\n\ +}\n\ +.edit-theme--solarflare .editor__inner .cn-toc, \n\ +.edit-theme--solarflare .editor__inner .code,\n\ +.edit-theme--solarflare .editor__inner .img,\n\ +.edit-theme--solarflare .editor__inner .img-wrapper,\n\ +.edit-theme--solarflare .editor__inner .imgref,\n\ +.edit-theme--solarflare .editor__inner .cl-toc {\n\ + color: rgba(139,158,177,0.8);\n\ + background-color: rgba(0,0,0,0.33);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--solarflare .editor__inner .cn-code {\n\ + color: #FC913A;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--solarflare .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--solarflare .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--solarflare .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--solarflare .editor__inner .linkref .cl-underlined-text {\n\ + color: #EDE574;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--solarflare .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--solarflare .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--solarflare .editor__inner .email,\n\ +.edit-theme--solarflare .editor__inner .cl-title,\n\ +.edit-theme--solarflare .editor__inner .tag,\n\ +.edit-theme--solarflare .editor__inner .latex,\n\ +.edit-theme--solarflare .editor__inner .math,\n\ +.edit-theme--solarflare .editor__inner .entity,\n\ +.edit-theme--solarflare .editor__inner .pre [class*='language-'] {\n\ + color: #e3e2e0;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_solarflare(); \ No newline at end of file diff --git a/static/themes/edit-theme-solarized_light.js b/static/themes/edit-theme-solarized_light.js new file mode 100644 index 0000000..e83fae3 --- /dev/null +++ b/static/themes/edit-theme-solarized_light.js @@ -0,0 +1,72 @@ +function init_edit_theme_solarized_light() { + const style = document.createElement('style'); + style.id = 'edit-theme-solarized_light'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--solarized_light .editor__inner {\n\ + color: #586E75;\n\ + caret-color: #586E75;\n\ +}\n\ +.edit-theme--solarized_light .editor {\n\ + background-color: #FDF6E3;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--solarized_light .editor__inner .cn-head,\n\ +.edit-theme--solarized_light .editor-in-page-buttons .icon {\n\ + color: #D33682;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--solarized_light .editor__inner .cn-strong {\n\ + color: #859900;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--solarized_light .editor__inner .blockquote {\n\ + color: #CB4B16;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--solarized_light .editor__inner .cl,\n\ +.edit-theme--solarized_light .editor__inner .hr,\n\ +.edit-theme--solarized_light .editor__inner .link,\n\ +.edit-theme--solarized_light .editor__inner .linkref, \n\ +.edit-theme--solarized_light .editor__inner .linkdef .url {\n\ + color: rgba(102,128,153,0.6);\n\ +}\n\ +.edit-theme--solarized_light .editor__inner .cn-toc, \n\ +.edit-theme--solarized_light .editor__inner .code,\n\ +.edit-theme--solarized_light .editor__inner .img,\n\ +.edit-theme--solarized_light .editor__inner .img-wrapper,\n\ +.edit-theme--solarized_light .editor__inner .imgref,\n\ +.edit-theme--solarized_light .editor__inner .cl-toc {\n\ + color: rgba(102,128,153,0.6);\n\ + background-color: rgba(102,128,153,0.075);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--solarized_light .editor__inner .cn-code {\n\ + color: #268BD2;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--solarized_light .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--solarized_light .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--solarized_light .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--solarized_light .editor__inner .linkref .cl-underlined-text {\n\ + color: #2AA198;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--solarized_light .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--solarized_light .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--solarized_light .editor__inner .email,\n\ +.edit-theme--solarized_light .editor__inner .cl-title,\n\ +.edit-theme--solarized_light .editor__inner .tag,\n\ +.edit-theme--solarized_light .editor__inner .latex,\n\ +.edit-theme--solarized_light .editor__inner .math,\n\ +.edit-theme--solarized_light .editor__inner .entity,\n\ +.edit-theme--solarized_light .editor__inner .pre [class*='language-'] {\n\ + color: #586E75;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_solarized_light(); \ No newline at end of file diff --git a/static/themes/edit-theme-spearmint.js b/static/themes/edit-theme-spearmint.js new file mode 100644 index 0000000..8c312ac --- /dev/null +++ b/static/themes/edit-theme-spearmint.js @@ -0,0 +1,72 @@ +function init_edit_theme_spearmint() { + const style = document.createElement('style'); + style.id = 'edit-theme-spearmint'; + style.type = 'text/css'; + style.innerHTML = "/* 默认字体颜色、光标颜色、背景颜色*/\n\ +.edit-theme--spearmint .editor__inner {\n\ + color: #719692;\n\ + caret-color: #719692;\n\ +}\n\ +.edit-theme--spearmint .editor {\n\ + background-color: #E1F0EE;\n\ +}\n\ +/* 标题颜色 */\n\ +.edit-theme--spearmint .editor__inner .cn-head,\n\ +.edit-theme--spearmint .editor-in-page-buttons .icon {\n\ + color: #199FA8;\n\ +}\n\ +/* 加粗颜色 */\n\ +.edit-theme--spearmint .editor__inner .cn-strong {\n\ + color: #69ADB5;\n\ +}\n\ +/* 信息块颜色 */\n\ +.edit-theme--spearmint .editor__inner .blockquote {\n\ + color: #25808A;\n\ +}\n\ +/* 源信息、md标记符号等非关键信息的颜色 */\n\ +.edit-theme--spearmint .editor__inner .cl,\n\ +.edit-theme--spearmint .editor__inner .hr,\n\ +.edit-theme--spearmint .editor__inner .link,\n\ +.edit-theme--spearmint .editor__inner .linkref, \n\ +.edit-theme--spearmint .editor__inner .linkdef .url {\n\ + color: rgba(102,128,153,0.6);\n\ +}\n\ +.edit-theme--spearmint .editor__inner .cn-toc, \n\ +.edit-theme--spearmint .editor__inner .code,\n\ +.edit-theme--spearmint .editor__inner .img,\n\ +.edit-theme--spearmint .editor__inner .img-wrapper,\n\ +.edit-theme--spearmint .editor__inner .imgref,\n\ +.edit-theme--spearmint .editor__inner .cl-toc {\n\ + color: rgba(102,128,153,0.6);\n\ + background-color: rgba(102,128,153,0.075);\n\ +}\n\ +/* 代码块颜色 */\n\ +.edit-theme--spearmint .editor__inner .cn-code {\n\ + color: #199FA8;\n\ +}\n\ +/* 链接颜色 */\n\ +.edit-theme--spearmint .editor__inner .img .cl-underlined-text,\n\ +.edit-theme--spearmint .editor__inner .imgref .cl-underlined-text,\n\ +.edit-theme--spearmint .editor__inner .link .cl-underlined-text,\n\ +.edit-theme--spearmint .editor__inner .linkref .cl-underlined-text {\n\ + color: #4CD7E0;\n\ +}\n\ +/* 图片原始链接背景颜色 */\n\ +.edit-theme--spearmint .editor__inner .img-wrapper .img {\n\ + background-color: transparent;\n\ +}\n\ +.edit-theme--spearmint .editor__inner .keyword {\n\ + color: #47596b;\n\ +}\n\ +.edit-theme--spearmint .editor__inner .email,\n\ +.edit-theme--spearmint .editor__inner .cl-title,\n\ +.edit-theme--spearmint .editor__inner .tag,\n\ +.edit-theme--spearmint .editor__inner .latex,\n\ +.edit-theme--spearmint .editor__inner .math,\n\ +.edit-theme--spearmint .editor__inner .entity,\n\ +.edit-theme--spearmint .editor__inner .pre [class*='language-'] {\n\ + color: #719692;\n\ +}"; + document.head.appendChild(style); +} +init_edit_theme_spearmint(); \ No newline at end of file diff --git a/static/themes/preview-theme-activeblue.js b/static/themes/preview-theme-activeblue.js new file mode 100644 index 0000000..6b79f2c --- /dev/null +++ b/static/themes/preview-theme-activeblue.js @@ -0,0 +1,216 @@ +function init_preview_theme_activeblue() { +const style = document.createElement('style'); +style.id = 'preview-theme-activeblue'; +style.type = 'text/css'; +style.innerHTML = "/** activeblue 灵动蓝\n \ +*/\n \ +.preview-theme--activeblue {\n \ + color: #333;\n \ + background-color: #fff;\n \ + font-family: -apple-system,system-ui,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Arial,sans-serif;\n \ +}\n \ +\n \ +/* 标题的通用设置 */\n \ +.preview-theme--activeblue h1,\n \ +.preview-theme--activeblue h2,\n \ +.preview-theme--activeblue h3,\n \ +.preview-theme--activeblue h4,\n \ +.preview-theme--activeblue h5,\n \ +.preview-theme--activeblue h6 {\n \ + padding: 30px 0;\n \ + margin: 0;\n \ + color: #135ce0;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--activeblue h1 {\n \ + position: relative;\n \ + margin-top: 30px;\n \ + margin-bottom: 10px;\n \ + text-align: center;\n \ +}\n \ +\n \ +/* 一级标题前缀,用来放背景图,支持透明度控制 */\n \ +.preview-theme--activeblue h1 .prefix {\n \ + display: inline-block;\n \ + top: 0;\n \ + width: 60px;\n \ + height: 60px;\n \ + background: url(https://imgs.qicoder.com/stackedit/ape_blue.svg);\n \ + background-size: 100% 100%;\n \ + opacity: .12;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--activeblue h1 .content {\n \ + font-size: 22px;\n \ + display: block;\n \ + margin-top: -36px;\n \ +}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--activeblue h2 {\n \ + position: relative;\n \ + font-size: 20px;\n \ +}\n \ +\n \ +/* 二级标题前缀,有兴趣加内容的可以魔改 */\n \ +.preview-theme--activeblue h2 .prefix {\n \ +\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--activeblue h2 .content {\n \ + border-left: 4px solid;\n \ + padding-left: 10px;\n \ +}\n \ +\n \ +/* 一二级标题之间间距控制一下 */\n \ +.preview-theme--activeblue h1 + h2 {\n \ + padding-top: 0;\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--activeblue h3 {\n \ + font-size: 16px;\n \ +}\n \ +\n \ +/* 段落 */\n \ +.preview-theme--activeblue p {\n \ + font-size: 16px;\n \ + line-height: 2;\n \ + font-weight: 400;\n \ +}\n \ +\n \ +/* 段落间距控制 */\n \ +.preview-theme--activeblue p+p {\n \ + margin-top: 16px;\n \ +}\n \ +\n \ +/* 无序列表 */\n \ +.preview-theme--activeblue ul>li ul>li {\n \ + list-style: circle;\n \ +}\n \ +\n \ +/* 无序列表内容行高 */\n \ +.preview-theme--activeblue li section {\n \ + line-height: 2;\n \ +}\n \ +\n \ +/* 引用 */\n \ +.preview-theme--activeblue blockquote {\n \ + border-left-color: #b2aec5 !important;\n \ + background: #fff9f9 !important;\n \ +}\n \ +\n \ +/* 引用文字 */\n \ +.preview-theme--activeblue blockquote p {\n \ + color: #666;\n \ + line-height: 2;\n \ +}\n \ +\n \ +/* 链接 */\n \ +.preview-theme--activeblue a {\n \ + color: #036aca;\n \ + border-bottom: 0;\n \ + font-weight: 400;\n \ + text-decoration: none;\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--activeblue strong {\n \ + background: linear-gradient(to right ,#3299d2,#efbdb5);\n \ + color: #fff;\n \ + font-weight: 400;\n \ + padding: 0 4px;\n \ + display: inline-block;\n \ + border-radius: 4px;\n \ + margin: 0 2px;\n \ + letter-spacing: 1px;\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--activeblue em strong {\n \ + color: #fff;\n \ +}\n \ +\n \ +/* 分隔线 */\n \ +.preview-theme--activeblue hr {\n \ + border-top: 1px solid #135ce0;\n \ +}\n \ +\n \ +/* 图片描述文字,隐藏了,如果需要,请删除display */\n \ +.preview-theme--activeblue figcaption {\n \ + display: none;\n \ + opacity: .6;\n \ + margin-top: 12px;\n \ + font-size: 12px;\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--activeblue p code,\n \ +.preview-theme--activeblue li code,\n \ +.preview-theme--activeblue table code {\n \ + background-color: rgba(0,0,0,.05);\n \ + color: #1394d8;\n \ + padding: 2px 6px;\n \ + word-break: normal;\n \ +}\n \ +\n \ +/* 表格 */\n \ +.preview-theme--activeblue table {\n \ + border-spacing: 0;\n \ +}\n \ +\n \ +/*\n \ +* 表格内的单元格\n \ +*/\n \ +.preview-theme--activeblue table tr th {\n \ + background-color: #d4f1ff;\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--activeblue .footnote-word {\n \ + color: #135ce0;\n \ + font-weight: 400;\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--activeblue .footnote-ref {\n \ + color: #5ba1e2;\n \ + font-weight: 400;\n \ +}\n \ +\n \ +/* 参考资料 */\n \ +.preview-theme--activeblue .footnotes-sep:before {\n \ + text-align: center;\n \ + color: #135ce0;\n \ + content: \"参考\";\n \ +}\n \ +\n \ +/* 参考编号 */\n \ +.preview-theme--activeblue .footnote-num {\n \ + color: #666;\n \ +}\n \ +\n \ +/* 参考文字 */\n \ +.preview-theme--activeblue .footnote-item p { \n \ + color: #999;\n \ + font-weight: 700;\n \ + font-style: italic;\n \ + font-size: 13px;\n \ +}\n \ +\n \ +/* 参考解释 */\n \ +.preview-theme--activeblue .footnote-item p em {\n \ + color: #3375e2;\n \ + font-style: normal;\n \ + margin-left: 4px;\n \ +}\n \ +.preview-theme--activeblue pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_activeblue(); diff --git a/static/themes/preview-theme-allblue.js b/static/themes/preview-theme-allblue.js new file mode 100644 index 0000000..2ddac13 --- /dev/null +++ b/static/themes/preview-theme-allblue.js @@ -0,0 +1,426 @@ +function init_preview_theme_allblue() { +const style = document.createElement('style'); +style.id = 'preview-theme-allblue'; +style.type = 'text/css'; +style.innerHTML = "/* 全栈蓝 */\n \ +\n \ +/* 全局属性\n \ +*/\n \ +.preview-theme--allblue {\n \ +line-height: 1.25;\n \ +color: #2b2b2b;\n \ +background-color: #fff;\n \ +font-family: Optima-Regular, Optima, PingFangTC-Light, PingFangSC-light, PingFangTC-light;\n \ +letter-spacing: 2px;\n \ +background-image: linear-gradient(90deg, rgba(50, 0, 0, 0.04) 3%, rgba(0, 0, 0, 0) 3%), linear-gradient(360deg, rgba(50, 0, 0, 0.04) 3%, rgba(0, 0, 0, 0) 3%);\n \ +background-size: 20px 20px;\n \ +background-position: center;\n \ +}\n \ +\n \ +/* 段落\n \ +*/\n \ +.preview-theme--allblue p {\n \ +color: #2b2b2b;\n \ +margin: 10px 0px;\n \ +letter-spacing: 2px;\n \ +font-size: 14px;\n \ +word-spacing: 2px;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--allblue h1 {\n \ +font-size: 25px;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--allblue h1 span {\n \ +display: inline-block;\n \ +font-weight: bold;\n \ +color: #40B8FA;\n \ +}\n \ +\n \ +/* 一级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--allblue h1:after {\n \ +position: unset;\n \ +display: unset;\n \ +border-bottom: unset;\n \ +}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--allblue h2 {\n \ +display:block;\n \ +border-bottom: 4px solid #40B8FA;\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--allblue h2 .content {\n \ +display: flex;\n \ +color: #40B8FA;\n \ +font-size: 20px;\n \ +margin-left: 25px;\n \ +}\n \ +\n \ +/* 二级标题前缀 */\n \ +.preview-theme--allblue h2 .prefix {\n \ +display: flex;\n \ +width: 20px;\n \ +height: 20px;\n \ +background-size: 20px 20px;\n \ +background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB00lEQVRYCe1Xy23DMAz1BB2jA+TSU2EBmSMzeIUM4WuHyKXWoWeP02sdS4CLJ4YW5ciKDUdICySAAMn68PHxiVSK4vn7Swy8fQ4vpbYH1ZyPORrOho2oz6r5UaU230r3Q84GG/uv4fUKhNKmJQC2BpgsTXcVbJTanAIAQASvS91/BBMZBjAOWwELzltQ35yPsElawEJbc8zQlwBpbE7Yuwan05azJfZNAYzjy8JwTCC9Tkx7dwDwGppAYwbg/XQ8K6gEokUMJPZvnooD0F1FlMJrW+dtsIGr3lWjNxj4mObNA96OAOCyn0Nl63fd73I2YhdX3h48A0g8TvGk8HEiQyeugf8MAJlNJqhbpN2VAdbOVW5PoFgNwNUJlGdt2iB/F0VBySkUFATMekJ/imUxAHjHhYOuTgwAlW/OljBGhY3vOsAhRF7xiwDI3A8vY57coh97mCFihIrPIgDwhAxIT8JSzexI75juwLB7Z6xkgA9iIGxMagBeoRhJ+rEe7NHDxrToy7NoHnpC6RdzI+WX98B0Ex8sv5OXIp3KyUR/cQgSZ2yaigIg5YLSMM6bLM1sjoXTLcU9p9g94FEKBF48ectx8hUFRbvr94g/JjMhe37OzsAvpzCHV7lWaToAAAAASUVORK5CYII=);\n \ +margin-bottom: -22px;\n \ +}\n \ +\n \ +/* 二级标题后缀 */\n \ +.preview-theme--allblue h2 .suffix {\n \ +display: flex;\n \ +box-sizing: border-box;\n \ +width: 200px;\n \ +height: 10px;\n \ +border-top-left-radius: 20px;\n \ +background: RGBA(64, 184, 250, .5);\n \ +color: rgb(255, 255, 255);\n \ +font-size: 16px;\n \ +letter-spacing: 0.544px;\n \ +justify-content: flex-end;\n \ +box-sizing: border-box !important;\n \ +overflow-wrap: break-word !important;\n \ +float: right;\n \ +margin-top: -10px;\n \ +}\n \ +\n \ +.preview-theme--allblue h2:after {\n \ +position: unset;\n \ +display: unset;\n \ +border-bottom: unset;\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--allblue h3 {\n \ +font-size: 17px;\n \ +font-weight: bold;\n \ +text-align: center;\n \ +position:relative;\n \ +margin-top: 20px;\n \ +margin-bottom: 20px;\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--allblue h3 .content {\n \ +border-bottom: 2px solid RGBA(79, 177, 249, .65);\n \ +color: #2b2b2b;\n \ +padding-bottom:2px\n \ +}\n \ +\n \ +.preview-theme--allblue h3 .content:before{\n \ +content:'';\n \ +width:30px;\n \ +height:30px;\n \ +display:block;\n \ +background-position:center;\n \ +background-size:30px;\n \ +margin:auto;\n \ +opacity:1;\n \ +background-repeat:no-repeat;\n \ +margin-bottom:-8px;\n \ +}\n \ +\n \ +/* 三级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--allblue h3:after {}\n \ +\n \ +.preview-theme--allblue h4 .content {\n \ +height:16px;\n \ +line-height:16px;\n \ +font-size: 16px;\n \ +}\n \ +\n \ +.preview-theme--allblue h4 .content:before{\n \ +content:'';\n \ +background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABXElEQVRYCe1WsRHCMAzMVHRcvAcz0KekZQ92iNMwCzPQ2SngxJ2MbMm2RCjDHRdb+X/L8kfJMOy/vQIbK3D08eB8fOF/o5yd7pZwwsWdX+92hY2M0YdrSmBZp41ydjrsGhOA47ArNBhuDhcU/1zncCnhzocHYuCehlNqiHONEDXg6OMThTRcxIpXrcC4xDPuvjSgVoMlYCG6Od5SAoIBLVqfRKwEjQHVmmogqRmcO1aAhNmwq90FMMlhoAaEJ0GAZKHqGtUbGZ1PMt4cbxzBIxkH2jc81mKQc1kkM6DQHxih1SN+SYI2IE0H7K7RBRRbQvPRBlRA0lStrQXmBmy/AbWapmzdsk5YAfBCIhcD8+LI7xFpA4J2jDx67WlQrDhuCdAGJBmwxRUXqwVrQqn8QgOqcWprmOMWA5rFNQRqQPgc03D+iqEGhA/Sv4prxL7nH1+SATUaO2avAK3AG91vsolsvFjsAAAAAElFTkSuQmCC);\n \ +display:inline-block;\n \ +width:16px;\n \ +height:16px;\n \ +background-size:100% ;\n \ +background-position:left bottom;\n \ +background-repeat:no-repeat;\n \ +width: 16px;\n \ +height: 15px;\n \ +line-height:15px;\n \ +margin-right:6px;\n \ +margin-bottom:-2px;\n \ +}\n \ +\n \ +/* 无序列表整体样式\n \ +* list-style-type: square|circle|disc;\n \ +*/\n \ +.preview-theme--allblue ul {\n \ +font-size: 15px; /*神奇逻辑,必须比li section的字体大才会在二级中生效*/\n \ +color: #595959;\n \ +list-style-type: circle;\n \ +}\n \ +\n \ +\n \ +/* 有序列表整体样式\n \ +* list-style-type: upper-roman|lower-greek|lower-alpha;\n \ +*/\n \ +.preview-theme--allblue ol {\n \ +font-size: 15px;\n \ +color: #595959;\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ +*/\n \ +.preview-theme--allblue li section {\n \ +font-size: 14px;\n \ +font-weight: normal;\n \ +color: #595959;\n \ +}\n \ +\n \ +/* 引用\n \ +* 左边缘颜色 border-left-color:black;\n \ +* 背景色 background:gray;\n \ +*/\n \ +.preview-theme--allblue blockquote::before {\n \ +content: \"❝\";\n \ +color: RGBA(64, 184, 250, .5);\n \ +font-size: 34px;\n \ +line-height: 1;\n \ +font-weight: 700;\n \ +}\n \ +\n \ +.preview-theme--allblue blockquote {\n \ +text-size-adjust: 100%;\n \ +line-height: 1.55em;\n \ +font-weight: 400;\n \ +border-radius: 6px;\n \ +color: #595959 !important;\n \ +font-style: normal;\n \ +text-align: left;\n \ +box-sizing: inherit;\n \ +border-left: none;\n \ +padding-bottom: 25px;\n \ +border: 1px solid RGBA(64, 184, 250, .4) !important;\n \ +background: RGBA(64, 184, 250, .1) !important;\n \ +}\n \ +\n \ +.preview-theme--allblue blockquote p {\n \ +color: #595959;\n \ +margin: 0px;\n \ +}\n \ +\n \ +.preview-theme--allblue blockquote::after {\n \ +content: \"❞\";\n \ +float: right;\n \ +line-height: 1;\n \ +color: RGBA(64, 184, 250, .5);\n \ +}\n \ +\n \ +/* 链接\n \ +* border-bottom: 1px solid #009688;\n \ +*/\n \ +.preview-theme--allblue a {\n \ +color: #40B8FA;\n \ +font-weight: normal;\n \ +border-bottom: 1px solid #3BAAFA;\n \ +}\n \ +\n \ +.preview-theme--allblue strong::before {\n \ +content: '「';\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--allblue strong {\n \ +color: #3594F7;\n \ +font-weight: bold;\n \ +}\n \ +\n \ +.preview-theme--allblue strong::after {\n \ +content: '」';\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--allblue em {\n \ +font-style: normal;\n \ +color: #3594F7;\n \ +font-weight:bold;\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--allblue em strong {\n \ +color: #3594F7;\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--allblue s {\n \ +color: #3594F7;\n \ +}\n \ +\n \ +/* 分隔线\n \ +* 粗细、样式和颜色\n \ +* border-top:1px solid #3e3e3e;\n \ +*/\n \ +.preview-theme--allblue hr {\n \ +height: 1px;\n \ +padding: 0;\n \ +border: none;\n \ +border-top: 2px solid #3BAAFA;\n \ +}\n \ +\n \ +/* 图片\n \ +* 宽度 width:80%;\n \ +* 居中 margin:0 auto;\n \ +* 居左 margin:0 0;\n \ +*/\n \ +.preview-theme--allblue img {\n \ +border-radius: 6px;\n \ +display: block;\n \ +margin: 20px auto;\n \ +object-fit: contain;\n \ +box-shadow:2px 4px 7px #999;\n \ +}\n \ +\n \ +/* 图片描述文字 */\n \ +.preview-theme--allblue figcaption {\n \ +text-align: center;\n \ +display: block;\n \ +font-size: 13px;\n \ +color: #2b2b2b;\n \ +}\n \ +\n \ +.preview-theme--allblue figcaption:before{\n \ +content:'';\n \ +background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgBAMAAACBVGfHAAAAGFBMVEVHcExAuPtAuPpAuPtAuPpAuPtAvPxAuPokzOX5AAAAB3RSTlMAkDLqNegkoiUM7wAAAGBJREFUKM9jYBhcgMkBTUDVBE1BeDGqEtXychNUBeXlKEqACsrLQxB8lnCQQClCiWt5OYoSiAIkJVAF5eVBqAqAShTAAs7l5ShKWMwRAmAlSArASpAVgJUkCqIAscESHwCVVjMBK9JnbQAAAABJRU5ErkJggg==);\n \ +display:inline-block;\n \ +width:18px;\n \ +height:18px;\n \ +background-size:18px;\n \ +background-repeat:no-repeat;\n \ +background-position:center;\n \ +margin-right:5px;\n \ +margin-bottom:-5px;\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--allblue p code,\n \ +.preview-theme--allblue li code {\n \ +color: #3594F7;\n \ +background: RGBA(59, 170, 250, .1);\n \ +padding:0 2px;\n \ +border-radius:2px;\n \ +height:21px;\n \ +line-height:22px;\n \ +}\n \ +\n \ +/* 非微信代码块\n \ +* 代码块不换行 display:-webkit-box !important;\n \ +* 代码块换行 display:block;\n \ +*/\n \ +.preview-theme--allblue .code-snippet__fix {\n \ +background: #f7f7f7;\n \ +border-radius: 2px;\n \ +}\n \ +\n \ +.preview-theme--allblue pre code {\n \ +letter-spacing: 0px;\n \ +}\n \ +\n \ +/*\n \ +* 表格内的单元格\n \ +* 字体大小 font-size: 16px;\n \ +* 边框 border: 1px solid #ccc;\n \ +* 内边距 padding: 5px 10px;\n \ +*/\n \ +.preview-theme--allblue table tr th,\n \ +.preview-theme--allblue table tr td {\n \ +font-size: 14px;\n \ +color: #595959;\n \ +}\n \ +\n \ +.preview-theme--allblue .footnotes {\n \ +background: #F6EEFF;\n \ +padding: 20px 20px 20px 20px;\n \ +font-size: 14px;\n \ +border: 0.8px solid #DEC6FB;\n \ +border-radius: 6px;\n \ +border: 1px solid #DEC6FB;\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--allblue .footnote-word {\n \ +font-weight: normal;\n \ +color: #595959;\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--allblue .footnote-ref {\n \ +font-weight: normal;\n \ +color: #595959;\n \ +}\n \ +\n \ +/*脚注链接样式*/\n \ +.preview-theme--allblue .footnote-item em {\n \ +font-size: 14px;\n \ +color: #595959;\n \ +display: block;\n \ +}\n \ +\n \ +.preview-theme--allblue .footnotes{\n \ +background: RGBA(53, 148, 247, .4);\n \ +padding: 20px 20px 20px 20px;\n \ +font-size: 14px;\n \ +border-radius: 6px;\n \ +border: 1px solid RGBA(53, 148, 247, 1);\n \ +}\n \ +\n \ +.preview-theme--allblue .footnotes-sep {\n \ +border-top: unset;\n \ +}\n \ +\n \ +/* \"参考资料\"四个字\n \ +* 内容 content: \"参考资料\";\n \ +*/\n \ +.preview-theme--allblue .footnotes-sep:before {\n \ +content: 'Reference';\n \ +color: #595959;\n \ +letter-spacing: 1px;\n \ +border-bottom: 2px solid RGBA(64, 184, 250, 1);\n \ +display: inline;\n \ +background: linear-gradient(white 60%, RGBA(64, 184, 250, .4) 40%);\n \ +font-size: 20px;\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--allblue .footnote-num {}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--allblue .footnote-item p {\n \ +color: #595959;\n \ +font-weight: bold;\n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--allblue .footnote-item p em {\n \ +font-weight: normal;\n \ +}\n \ +\n \ +/* 行间公式\n \ +* 最大宽度 max-width: 300% !important;\n \ +*/\n \ +.preview-theme--allblue .katex--display svg {}\n \ +\n \ +/* 行内公式\n \ +*/\n \ +.preview-theme--allblue .katex--inline svg {}\n \ +\n \ +/* \n \ + */\n \ +.preview-theme--allblue pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}\n \ +\n \ +.preview-theme--allblue .language-mermaid {\n \ +letter-spacing: 0;\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_allblue(); diff --git a/static/themes/preview-theme-caoyuangreen.js b/static/themes/preview-theme-caoyuangreen.js new file mode 100644 index 0000000..6b49abd --- /dev/null +++ b/static/themes/preview-theme-caoyuangreen.js @@ -0,0 +1,382 @@ +function init_preview_theme_caoyuangreen() { +const style = document.createElement('style'); +style.id = 'preview-theme-caoyuangreen'; +style.type = 'text/css'; +style.innerHTML = "/* 草原绿 caoyuangreen\n \ +*/\n \ +.preview-theme--caoyuangreen {\n \ + line-height: 1.35;\n \ + color: #333;\n \ + background-color: #fff;\n \ + font-family: Optima-Regular, PingFangTC-light;\n \ + letter-spacing: 1.5px;\n \ +}\n \ +\n \ +/* 段落,下方未标注标签参数均同此处\n \ +*/\n \ +.preview-theme--caoyuangreen p {\n \ + color: #2b2b2b;\n \ + margin: 10px 0px;\n \ + letter-spacing: 2px;\n \ + font-size: 16px;\n \ + word-spacing: 2px;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--caoyuangreen h1 {\n \ + font-size: 25px;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--caoyuangreen h1 span {\n \ + display: inline-block;\n \ + font-weight: bold;\n \ + color: #4CAF50;\n \ +}\n \ +\n \ +/* 一级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--caoyuangreen h1:after {}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--caoyuangreen h2 {\n \ + display:block;\n \ + border-bottom: 4px solid #4CAF50;\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--caoyuangreen h2 .content {\n \ + display: flex;\n \ + color: #4CAF50;\n \ + font-size: 20px;\n \ +\n \ +}\n \ +\n \ +/* 二级标题前缀 */\n \ +.preview-theme--caoyuangreen h2 .prefix {\n \ +\n \ +}\n \ +\n \ +/* 二级标题后缀 */\n \ +.preview-theme--caoyuangreen h2 .suffix {\n \ + display: flex;\n \ + box-sizing: border-box;\n \ + width: 20px;\n \ + height: 10px;\n \ + border-top-left-radius: 20px;\n \ + border-top-right-radius: 20px;\n \ + background: RGBA(76, 175, 80, .5);\n \ + color: rgb(255, 255, 255);\n \ + font-size: 16px;\n \ + letter-spacing: 0.544px;\n \ + justify-content: flex-end;\n \ + box-sizing: border-box !important;\n \ + overflow-wrap: break-word !important;\n \ + float: right;\n \ + margin-top: -10px;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen h1:after, .preview-theme--caoyuangreen h2:after {\n \ + border-bottom: unset;\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--caoyuangreen h3 {\n \ + font-size: 17px;\n \ + font-weight: bold;\n \ + text-align: center;\n \ + position:relative;\n \ + margin-top: 20px;\n \ + margin-bottom: 20px;\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--caoyuangreen h3 .content {\n \ + color: #2b2b2b;\n \ + padding-bottom:2px\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen h3 .content:before{\n \ + content:'';\n \ + width:30px;\n \ + height:30px;\n \ + display:block;\n \ + background-image:url(https://imgs.qicoder.com/stackedit/grass-green.png);\n \ + background-position:center;\n \ + background-size:30px;\n \ + margin:auto;\n \ + opacity:1;\n \ + background-repeat:no-repeat;\n \ + margin-bottom:-8px;\n \ +}\n \ +\n \ +/* 三级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--caoyuangreen h3:after {}\n \ +\n \ +.preview-theme--caoyuangreen h4 .content {\n \ + height:16px;\n \ + line-height:16px;\n \ + font-size: 16px;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen h4 .content:before{\n \ +\n \ +}\n \ +\n \ +/* 无序列表整体样式\n \ +* list-style-type: square|circle|disc;\n \ +*/\n \ +.preview-theme--caoyuangreen ul {\n \ + font-size: 15px; /*神奇逻辑,必须比li section的字体大才会在二级中生效*/\n \ + color: #595959;\n \ + list-style-type: circle;\n \ +}\n \ +\n \ +\n \ +/* 有序列表整体样式\n \ +* list-style-type: upper-roman|lower-greek|lower-alpha;\n \ +*/\n \ +.preview-theme--caoyuangreen ol {\n \ + font-size: 15px;\n \ + color: #595959;\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ +*/\n \ +.preview-theme--caoyuangreen li section {\n \ + font-size: 16px;\n \ + font-weight: normal;\n \ + color: #595959;\n \ +}\n \ +\n \ +/* 引用\n \ +* 左边缘颜色 border-left-color:black;\n \ +* 背景色 background:gray;\n \ +*/\n \ +.preview-theme--caoyuangreen blockquote::before {\n \ + content: \"❝\";\n \ + color: #74b56d;\n \ + font-size: 34px;\n \ + line-height: 1;\n \ + font-weight: 700;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen blockquote {\n \ + text-size-adjust: 100%;\n \ + line-height: 1.55em;\n \ + font-weight: 400;\n \ + border-radius: 6px;\n \ + color: #595959 !important;\n \ + font-style: normal;\n \ + text-align: left;\n \ + box-sizing: inherit;\n \ + padding-bottom: 25px;\n \ + border-left: none !important;\n \ + border: 1px solid #1b900d !important;\n \ + background: #fff !important;\n \ +\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen blockquote p {\n \ + margin: 0px;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen blockquote::after {\n \ + content: \"❞\";\n \ + float: right;\n \ + color: #74b56d;\n \ +}\n \ +\n \ +/* 链接\n \ +* border-bottom: 1px solid #009688;\n \ +*/\n \ +.preview-theme--caoyuangreen a {\n \ + color: #399003;\n \ + font-weight: normal;\n \ + border-bottom: 1px solid #399003;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen strong::before {\n \ + content: '「';\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--caoyuangreen strong {\n \ + color: #399003;\n \ + font-weight: bold;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen strong::after {\n \ + content: '」';\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--caoyuangreen em {\n \ + font-style: normal;\n \ + color: #399003;\n \ + font-weight:bold;\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--caoyuangreen em strong {\n \ + color: #399003;\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--caoyuangreen del {\n \ + color: #399003;\n \ +}\n \ +\n \ +/* 分隔线\n \ +* 粗细、样式和颜色\n \ +* border-top:1px solid #3e3e3e;\n \ +*/\n \ +.preview-theme--caoyuangreen hr {\n \ + height: 1px;\n \ + padding: 0;\n \ + border: none;\n \ + border-top: 2px solid #399003;\n \ +}\n \ +\n \ +/* 图片\n \ +* 宽度 width:80%;\n \ +* 居中 margin:0 auto;\n \ +* 居左 margin:0 0;\n \ +*/\n \ +.preview-theme--caoyuangreen img {\n \ + border-radius: 6px;\n \ + display: block;\n \ + margin: 20px auto;\n \ + object-fit: contain;\n \ + box-shadow:2px 4px 7px #999;\n \ +}\n \ +\n \ +/* 图片描述文字 */\n \ +.preview-theme--caoyuangreen figcaption {\n \ + display: block;\n \ + font-size: 13px;\n \ + color: #2b2b2b;\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--caoyuangreen p code,\n \ +.preview-theme--caoyuangreen li code,\n \ +.preview-theme--caoyuangreen table code {\n \ + color: #0bb712;\n \ + background: rgba(127, 226, 159, 0.48);\n \ + display:inline-block;\n \ + padding:0 2px;\n \ + border-radius:2px;\n \ + height:21px;\n \ + line-height:22px;\n \ +}\n \ +\n \ +/* 非微信代码块\n \ +* 代码块不换行 display:-webkit-box !important;\n \ +* 代码块换行 display:block;\n \ +*/\n \ +.preview-theme--caoyuangreen .code-snippet__fix {\n \ + background: #f7f7f7;\n \ + border-radius: 2px;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen pre code {\n \ + letter-spacing: 0px;\n \ +}\n \ +\n \ +/*\n \ +* 表格内的单元格\n \ +* 字体大小 font-size: 16px;\n \ +* 边框 border: 1px solid #ccc;\n \ +* 内边距 padding: 5px 10px;\n \ +*/\n \ +.preview-theme--caoyuangreen table tr th,\n \ +.preview-theme--caoyuangreen table tr td {\n \ + font-size: 16px;\n \ + color: #595959;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen .footnotes {\n \ + background: #F6EEFF;\n \ + padding: 20px 20px 20px 20px;\n \ + font-size: 16px;\n \ + border: 0.8px solid #399003;\n \ + border-radius: 6px;\n \ + border: 1px solid #399003;\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--caoyuangreen .footnote-word {\n \ + font-weight: normal;\n \ + color: #595959;\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--caoyuangreen .footnote-ref {\n \ + font-weight: normal;\n \ + color: #595959;\n \ +}\n \ +\n \ +/*脚注链接样式*/\n \ +.preview-theme--caoyuangreen .footnote-item em {\n \ + font-size: 16px;\n \ + color: #595959;\n \ + display: block;\n \ +}\n \ +\n \ +.preview-theme--caoyuangreen .footnotes{\n \ + background: #fff;\n \ + padding: 20px 20px 20px 20px;\n \ + font-size: 16px;\n \ + border-radius: 6px;\n \ + border: 1px solid #4CAF50;\n \ +}\n \ +\n \ +/* \"参考资料\"四个字\n \ +* 内容 content: \"参考资料\";\n \ +*/\n \ +.preview-theme--caoyuangreen .footnotes-sep:before {\n \ + content: 'Reference';\n \ + color: #595959;\n \ + letter-spacing: 1px;\n \ + border-bottom: 2px solid #4CAF50;\n \ + display: inline;\n \ + font-size: 20px;\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--caoyuangreen .footnote-num {}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--caoyuangreen .footnote-item p {\n \ + color: #595959;\n \ + font-weight: bold;\n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--caoyuangreen .footnote-item p em {\n \ + font-weight: normal;\n \ +}\n \ +\n \ +/* 行间公式\n \ +* 最大宽度 max-width: 300% !important;\n \ +*/\n \ +.preview-theme--caoyuangreen .block-equation svg {}\n \ +\n \ +/* 行内公式\n \ +*/\n \ +.preview-theme--caoyuangreen .inline-equation svg {}\n \ +\n \ +/* 滑动图片\n \ + */\n \ +.preview-theme--caoyuangreen .imageflow-img {\n \ + display: inline-block;\n \ + width:100%;\n \ + margin-bottom: 0;\n \ +}\n \ +.preview-theme--caoyuangreen pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_caoyuangreen(); diff --git a/static/themes/preview-theme-jikebrack.js b/static/themes/preview-theme-jikebrack.js new file mode 100644 index 0000000..b5f0640 --- /dev/null +++ b/static/themes/preview-theme-jikebrack.js @@ -0,0 +1,273 @@ +function init_preview_theme_jikebrack() { +const style = document.createElement('style'); +style.id = 'preview-theme-jikebrack'; +style.type = 'text/css'; +style.innerHTML = "/*极客黑样式,实时生效*/\n \ +\n \ +/* 全局属性\n \ + */\n \ +.preview-theme--jikebrack {\n \ +color: #2b2b2b;\n \ +background-color: #fff;\n \ +}\n \ +\n \ +/* 段落\n \ + */\n \ +.preview-theme--jikebrack p {\n \ +box-sizing: border-box;\n \ +margin-bottom: 16px;\n \ +font-family: \"Helvetica Neue\", Helvetica, \"Segoe UI\", Arial, freesans, sans-serif;\n \ +font-size: 15px;\n \ +text-align: start;\n \ +white-space: normal;\n \ +text-size-adjust: auto;\n \ +line-height: 1.75em;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--jikebrack h1 {\n \ +margin-top: -0.46em;\n \ +margin-bottom: 0.1em;\n \ +border-bottom: 2px solid rgb(198, 196, 196);\n \ +box-sizing: border-box;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--jikebrack h1 .content {\n \ +padding-top: 5px;\n \ +padding-bottom: 5px;\n \ +color: rgb(160, 160, 160);\n \ +font-size: 13px;\n \ +line-height: 2;\n \ +box-sizing: border-box;\n \ +}\n \ +\n \ +/* 一级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--jikebrack h1:after {\n \ +}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--jikebrack h2 {\n \ +margin: 10px auto;\n \ +height: 40px;\n \ +background-color: rgb(251, 251, 251);\n \ +border-bottom: 1px solid rgb(246, 246, 246);\n \ +overflow: hidden;\n \ +box-sizing: border-box;\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--jikebrack h2 .content {\n \ +margin-left: -10px;\n \ +display: inline-block;\n \ +width: auto;\n \ +height: 40px;\n \ +background-color: rgb(33, 33, 34);\n \ +border-bottom-right-radius:100px;\n \ +color: rgb(255, 255, 255);\n \ +padding-right: 30px;\n \ +padding-left: 30px;\n \ +line-height: 40px;\n \ +font-size: 16px;\n \ +}\n \ +\n \ +/* 二级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--jikebrack h2:after {\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--jikebrack h3 {\n \ +margin: 20px auto 5px;\n \ +border-top: 1px solid rgb(221, 221, 221);\n \ +box-sizing: border-box;\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--jikebrack h3 .content {\n \ +margin-top: -1px;\n \ +padding-top: 6px;\n \ +padding-right: 5px;\n \ +padding-left: 5px;\n \ +font-size: 17px;\n \ +border-top: 2px solid rgb(33, 33, 34);\n \ +display: inline-block;\n \ +line-height: 1.1;\n \ +}\n \ +\n \ +/* 三级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--jikebrack h3:after {\n \ +}\n \ +\n \ +.preview-theme--jikebrack h4 {\n \ +margin: 10px auto -1px;\n \ +border-top: 1px solid rgb(221, 221, 221);\n \ +box-sizing: border-box;\n \ +}\n \ +\n \ +.preview-theme--jikebrack h4 .content {\n \ +margin-top: -1px;\n \ +padding-top: 6px;\n \ +padding-right: 5px;\n \ +padding-left: 5px;\n \ +font-size: 16px;\n \ +border-top: 2px solid rgb(33, 33, 34);\n \ +display: inline-block;\n \ +line-height: 1.1;\n \ +}\n \ +\n \ +/* 无序列表整体样式\n \ + * list-style-type: square|circle|disc;\n \ + */\n \ +.preview-theme--jikebrack ul {\n \ +}\n \ +\n \ +/* 有序列表整体样式\n \ + * list-style-type: upper-roman|lower-greek|lower-alpha;\n \ + */\n \ +.preview-theme--jikebrack ol {\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ + */\n \ +.preview-theme--jikebrack li section {\n \ +font-size: 15px;\n \ +font-family: \"Helvetica Neue\", Helvetica, \"Segoe UI\", Arial, freesans, sans-serif;\n \ +}\n \ +\n \ +/* 引用\n \ + * 左边缘颜色 border-left-color: black;\n \ + * 背景色 background: gray;\n \ + */\n \ +.preview-theme--jikebrack blockquote {\n \ +border-left-color: rgb(221, 221, 221) !important;\n \ +margin-top: 1.2em;\n \ +margin-bottom: 1.2em;\n \ +padding-right: 1em;\n \ +padding-left: 1em;\n \ +border-left-width: 4px;\n \ +color: rgb(119, 119, 119) !important;\n \ +quotes: none;\n \ +background: rgba(0, 0, 0, 0.05) !important;\n \ +}\n \ +\n \ +/* 引用文字 */\n \ +.preview-theme--jikebrack blockquote p {\n \ +margin: 0px;\n \ +font-size: 15px;\n \ +font-family: -apple-system-font, BlinkMacSystemFont, \"Helvetica Neue\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei UI\", \"Microsoft YaHei\", Arial, sans-serif;\n \ +color: rgb(119, 119, 119);\n \ +line-height: 1.75em;\n \ +}\n \ +\n \ +/* 链接 \n \ + * border-bottom: 1px solid #009688;\n \ + */\n \ +.preview-theme--jikebrack a {\n \ +color: rgb(239, 112, 96);\n \ +border-bottom: 1px solid rgb(239, 112, 96);\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--jikebrack strong {\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--jikebrack em {\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--jikebrack em strong {\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--jikebrack s {\n \ +}\n \ +\n \ +/* 分隔线\n \ + * 粗细、样式和颜色\n \ + * border-top: 1px solid #3e3e3e;\n \ + */\n \ +.preview-theme--jikebrack hr {\n \ +}\n \ +\n \ +/* 图片\n \ + * 宽度 width: 80%;\n \ + * 居中 margin: 0 auto;\n \ + * 居左 margin: 0 0;\n \ + */\n \ +.preview-theme--jikebrack img {\n \ +}\n \ +\n \ +/* 图片描述文字 */\n \ +.preview-theme--jikebrack figcaption {\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--jikebrack p code,.preview-theme--jikebrack li code {\n \ +color: rgb(239, 112, 96) !important;\n \ +background-color: rgba(27,31,35,.05) !important;\n \ +}\n \ +\n \ +/* 非微信代码块\n \ + * 代码块不换行 display: -webkit-box !important;\n \ + * 代码块换行 display: block;\n \ + */\n \ +.preview-theme--jikebrack pre code {\n \ +}\n \ +\n \ +/*\n \ + * 表格内的单元格\n \ + * 字体大小 font-size: 16px;\n \ + * 边框 border: 1px solid #ccc;\n \ + * 内边距 padding: 5px 10px;\n \ + */\n \ +.preview-theme--jikebrack table tr th,\n \ +.preview-theme--jikebrack table tr td {\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--jikebrack .footnote-word {\n \ +color: #ff3502;\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--jikebrack .footnote-ref {\n \ +color: rgb(239, 112, 96);\n \ +}\n \ +\n \ +/* \"参考资料\"四个字 \n \ + * 内容 content: \"参考资料\";\n \ + */\n \ +.preview-theme--jikebrack .footnotes-sep:before {\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--jikebrack .footnote-num {\n \ +}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--jikebrack .footnote-item p { \n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--jikebrack .footnote-item p em {\n \ +}\n \ +\n \ +/* 行间公式\n \ + * 最大宽度 max-width: 300% !important;\n \ + */\n \ +.preview-theme--jikebrack .block-equation svg {\n \ +}\n \ +\n \ +/* 行内公式\n \ + */\n \ +.preview-theme--jikebrack .inline-equation svg {\n \ +}\n \ +\n \ +.preview-theme--jikebrack pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_jikebrack(); diff --git a/static/themes/preview-theme-ningyezi.js b/static/themes/preview-theme-ningyezi.js new file mode 100644 index 0000000..6739277 --- /dev/null +++ b/static/themes/preview-theme-ningyezi.js @@ -0,0 +1,269 @@ +function init_preview_theme_ningyezi() { +const style = document.createElement('style'); +style.id = 'preview-theme-ningyezi'; +style.type = 'text/css'; +style.innerHTML = "/*凝夜紫 ningyezi\n \ +*/\n \ +.preview-theme--ningyezi {\n \ + line-height: 1.5;\n \ + font-family: Optima-Regular, Optima, PingFangTC-Light, PingFangSC-light, PingFangTC-light;\n \ + letter-spacing: 2px;\n \ + color: #2b2b2b;\n \ + background-color: #fff;\n \ + background-image: linear-gradient(90deg, rgba(50, 0, 0, 0.05) 3%, rgba(0, 0, 0, 0) 3%), linear-gradient(360deg, rgba(50, 0, 0, 0.05) 3%, rgba(0, 0, 0, 0) 3%);\n \ + background-size: 20px 20px;\n \ + background-position: center center;\n \ +}\n \ +\n \ +/* 段落,下方未标注标签参数均同此处\n \ + */\n \ +.preview-theme--ningyezi p {\n \ + margin: 10px 0px;\n \ + letter-spacing: 2px;\n \ + font-size: 14px;\n \ + word-spacing: 2px;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--ningyezi h1 {\n \ + font-size: 25px;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--ningyezi h1 .content {\n \ + display: inline-block;\n \ + font-weight: bold;\n \ + color: #773098;\n \ +}\n \ +\n \ +/* 一级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--ningyezi h1:after {}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--ningyezi h2 {\n \ + text-align: left;\n \ + margin: 20px 10px 0px 0px;\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--ningyezi h2 .content {\n \ + font-size: 18px;\n \ + font-weight: bold;\n \ + display: inline-block;\n \ + padding-left: 10px;\n \ + border-left: 5px solid #916dd5;\n \ +}\n \ +\n \ +/* 二级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--ningyezi h2:after {}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--ningyezi h3 {\n \ + font-size: 16px;\n \ + font-weight: bold;\n \ + text-align: center;\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--ningyezi h3 .content {\n \ + border-bottom: 2px solid #d89cf6;\n \ +}\n \ +\n \ +/* 三级标题修饰 请参考有实例的主题 */\n \ +.preview-theme--ningyezi h3:after {}\n \ +\n \ +/* 无序列表整体样式\n \ + * list-style-type: square|circle|disc;\n \ + */\n \ +.preview-theme--ningyezi ul {\n \ + font-size: 15px;\n \ + /*神奇逻辑,必须比li section的字体大才会在二级中生效*/\n \ + list-style-type: circle;\n \ +}\n \ +\n \ +\n \ +/* 有序列表整体样式\n \ + * list-style-type: upper-roman|lower-greek|lower-alpha;\n \ + */\n \ +.preview-theme--ningyezi ol {\n \ + font-size: 15px;\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ + */\n \ +.preview-theme--ningyezi li section {\n \ + font-size: 14px;\n \ + font-weight: normal;\n \ +}\n \ +\n \ +/* 引用\n \ + * 左边缘颜色 border-left-color:black;\n \ + * 背景色 background:gray;\n \ + */\n \ +.preview-theme--ningyezi blockquote {\n \ + color: rgba(0,0,0,0.5) !important;\n \ + border-left-color: #d89cf6 !important;\n \ + background: #f4eeff !important;\n \ +}\n \ +\n \ +/* 链接 \n \ + * border-bottom: 1px solid #009688;\n \ + */\n \ +.preview-theme--ningyezi a {\n \ + color: #916dd5;\n \ + font-weight: bolder;\n \ + border-bottom: 1px solid #916dd5;\n \ +}\n \ +\n \ +.preview-theme--ningyezi strong::before {\n \ + content: '「';\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--ningyezi strong {\n \ + color: #916dd5;\n \ + font-weight: bold;\n \ +}\n \ +\n \ +.preview-theme--ningyezi strong::after {\n \ + content: '」';\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--ningyezi em {\n \ + font-style: normal;\n \ + color: #916dd5;\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--ningyezi em strong {\n \ + color: #916dd5;\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--ningyezi del {\n \ + color: #916dd5;\n \ +}\n \ +\n \ +/* 分隔线\n \ + * 粗细、样式和颜色\n \ + */\n \ +.preview-theme--ningyezi hr {\n \ + height: 1px;\n \ + padding: 0;\n \ + border: none;\n \ + border-top: 2px solid #d9b8fa;\n \ +}\n \ +\n \ +/* 图片\n \ + * 宽度 width:80%;\n \ + * 居中 margin:0 auto;\n \ + * 居左 margin:0 0;\n \ + */\n \ +.preview-theme--ningyezi img {\n \ + border-radius: 6px;\n \ + display: block;\n \ + margin: 20px auto;\n \ + object-fit: contain;\n \ + box-shadow: 2px 4px 7px #999;\n \ +}\n \ +\n \ +/* 图片描述文字 */\n \ +.preview-theme--ningyezi figcaption {\n \ + display: block;\n \ + font-size: 13px;\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--ningyezi p code,\n \ +.preview-theme--ningyezi li code,\n \ +.preview-theme--ningyezi table code {\n \ + color: #916dd5;\n \ + font-weight: bolder;\n \ + background: none;\n \ +}\n \ +\n \ +/* 非微信代码块\n \ + * 代码块不换行 display:-webkit-box !important;\n \ + * 代码块换行 display:block;\n \ + */\n \ +.preview-theme--ningyezi .code-snippet__fix {\n \ + background: #f7f7f7;\n \ + border-radius: 2px;\n \ +}\n \ +\n \ +.preview-theme--ningyezi pre code {}\n \ +\n \ +/*\n \ + * 表格内的单元格\n \ + * 字体大小 font-size: 16px;\n \ + * 边框 border: 1px solid #ccc;\n \ + * 内边距 padding: 5px 10px;\n \ + */\n \ +.preview-theme--ningyezi table tr th,\n \ +.preview-theme--ningyezi table tr td {\n \ + font-size: 14px;\n \ +}\n \ +\n \ +.preview-theme--ningyezi .footnotes {\n \ + font-size: 14px;\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--ningyezi .footnote-word {\n \ + font-weight: normal;\n \ + color: #916dd5;\n \ + font-weight: bold;\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--ningyezi .footnote-ref {\n \ + font-weight: normal;\n \ + color: #916dd5;\n \ +}\n \ +\n \ +/*脚注链接样式*/\n \ +.preview-theme--ningyezi .footnote-item em {\n \ + font-size: 14px;\n \ + color: #916dd5;\n \ + display: block;\n \ +}\n \ +\n \ +/* \"参考资料\"四个字 \n \ + * 内容 content: \"参考资料\";\n \ + */\n \ +.preview-theme--ningyezi .footnotes-sep:before {\n \ + font-size: 20px;\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--ningyezi .footnote-num {\n \ + color: #916dd5;\n \ +}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--ningyezi .footnote-item p {\n \ + color: #916dd5;\n \ + font-weight: bold;\n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--ningyezi .footnote-item p em {\n \ + font-weight: normal;\n \ +}\n \ +\n \ +/* 行间公式\n \ + * 最大宽度 max-width: 300% !important;\n \ + */\n \ +.preview-theme--ningyezi .block-equation svg {}\n \ +\n \ +/* 行内公式\n \ + */\n \ +.preview-theme--ningyezi .inline-equation svg {}\n \ +.preview-theme--ningyezi pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_ningyezi(); diff --git a/static/themes/preview-theme-simplebrack.js b/static/themes/preview-theme-simplebrack.js new file mode 100644 index 0000000..69f471b --- /dev/null +++ b/static/themes/preview-theme-simplebrack.js @@ -0,0 +1,340 @@ +function init_preview_theme_simplebrack() { +const style = document.createElement('style'); +style.id = 'preview-theme-simplebrack'; +style.type = 'text/css'; +style.innerHTML = "/* 全局属性\n \ + * 页边距 padding: 30px;\n \ + * 全文字体 font-family: ptima-Regular;\n \ + * 英文换行 word-break: break-all;\n \ + */\n \ + .preview-theme--simplebrack {\n \ +font-size:14px;\n \ +padding:10px;\n \ +color: #2b2b2b;\n \ +background-color: #fff;\n \ +}\n \ +\n \ +/*图片下提示*/\n \ +.preview-theme--simplebrack figcaption{\n \ +font-size:12px;\n \ +}\n \ +.preview-theme--simplebrack .imageflow-caption{\n \ +font-size:12px;\n \ +}\n \ +\n \ +/* 段落,下方未标注标签参数均同此处\n \ + * 上边距 margin-top: 5px;\n \ + * 下边距 margin-bottom: 5px;\n \ + * 行高 line-height: 26px;\n \ + * 词间距 word-spacing: 3px;\n \ + * 字间距 letter-spacing: 3px;\n \ + * 对齐 text-align: left;\n \ + * 颜色 color: #3e3e3e;\n \ + * 字体大小 font-size: 16px;\n \ + * 首行缩进 text-indent: 2em;\n \ + */\n \ +.preview-theme--simplebrack p {\n \ +font-size:14px;\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--simplebrack h1 {\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--simplebrack h1 .content {\n \ +}\n \ +\n \ +/* 一级标题前缀 */\n \ +.preview-theme--simplebrack h1 .prefix {\n \ +}\n \ +\n \ +/* 一级标题后缀 */\n \ +.preview-theme--simplebrack h1 .suffix{\n \ +}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--simplebrack h2 {\n \ +text-align:center;\n \ +position:relative;\n \ +font-weight: bold;\n \ +color: black;\n \ +line-height: 1.1em;\n \ +padding-top: 12px;\n \ +padding-bottom: 12px;\n \ +margin:70px 30px 30px;\n \ +border: 1px solid #000;\n \ +}\n \ +\n \ +.preview-theme--simplebrack h2:before{\n \ +content: ' ';\n \ +float: left;\n \ +display: block;\n \ +width: 90%;\n \ +border-top: 1px solid #000;\n \ +height: 1px;\n \ +line-height: 1px;\n \ +margin-left: -5px;\n \ +margin-top: -17px;\n \ +}\n \ +.preview-theme--simplebrack h2:after{\n \ +content: ' ';\n \ +float: right;\n \ +display: block;\n \ +width: 90%;\n \ +border-bottom: 1px solid #000;\n \ +height: 1px;\n \ +line-height: 1px;\n \ +margin-right: -5px;\n \ +margin-top: 16px;\n \ +position: unset;\n \ +}\n \ +/* 二级标题内容 */\n \ +.preview-theme--simplebrack h2 .content {\n \ +display: block;\n \ +-webkit-box-reflect: below 0em -webkit-gradient(linear,left top,left bottom, from(rgba(0,0,0,0)),to(rgba(255,255,255,0.1)));\n \ +}\n \ +.preview-theme--simplebrack h2 strong {\n \ +}\n \ +/* 二级标题前缀 */\n \ +.preview-theme--simplebrack h2 .prefix {\n \ +display: block;\n \ +width: 3px;\n \ +margin: 0 0 0 5%;\n \ +height: 3px;\n \ +line-height: 3px;\n \ +overflow: hidden;\n \ +background-color: #000;\n \ +box-shadow:3px 0 #000,\n \ +0 3px #000,\n \ +-3px 0 #000,\n \ +0 -3px #000;\n \ +}\n \ +\n \ +/* 二级标题后缀 */\n \ +.preview-theme--simplebrack h2 .suffix {\n \ +display: block;\n \ +width: 3px;\n \ +margin: 0 0 0 95%;\n \ +height: 3px;\n \ +line-height: 3px;\n \ +overflow: hidden;\n \ +background-color: #000;\n \ +box-shadow:3px 0 #000,\n \ +0 3px #000,\n \ +-3px 0 #000,\n \ +0 -3px #000;\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--simplebrack h3 {\n \ +background-color:#000;\n \ +color:#fff;\n \ +padding:2px 10px;\n \ +width:fit-content;\n \ +font-size:17px;\n \ +margin:60px auto 10px;\n \ +}\n \ +.preview-theme--simplebrack h3 strong {\n \ +color:#fff;\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--simplebrack h3 .content {\n \ +}\n \ +\n \ +/* 三级标题前缀 */\n \ +.preview-theme--simplebrack h3 .prefix {\n \ +}\n \ +\n \ +/* 三级标题后缀 */\n \ +.preview-theme--simplebrack h3 .suffix {\n \ +}\n \ +\n \ +/* 无序列表整体样式\n \ + * list-style-type: square|circle|disc;\n \ + */\n \ +.preview-theme--simplebrack ul {\n \ +list-style-type: square;\n \ +}\n \ +/* 无序二级列表\n \ + */\n \ +.preview-theme--simplebrack ul li ul li{\n \ +list-style-type: circle;\n \ +}\n \ +\n \ +/* 有序列表整体样式\n \ + * list-style-type: upper-roman|lower-greek|lower-alpha;\n \ + */\n \ +.preview-theme--simplebrack ol {\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ + */\n \ +.preview-theme--simplebrack li section {\n \ +}\n \ +\n \ +/* 引用\n \ + * 左边缘颜色 border-left-color: black;\n \ + * 背景色 background: gray;\n \ + */\n \ +.preview-theme--simplebrack blockquote {\n \ +border-left: 3px solid rgba(0, 0, 0, 0.65) !important;\n \ +border-right: 1px solid rgba(0, 0, 0, 0.65) !important;\n \ +background: rgb(249, 249, 249) !important;\n \ +color: rgba(0,0,0,0.5) !important;\n \ +}\n \ +\n \ +/* 引用文字 */\n \ +.preview-theme--simplebrack blockquote p {\n \ +}\n \ +\n \ +/* 链接 \n \ + * border-bottom: 1px solid #009688;\n \ + */\n \ +.preview-theme--simplebrack a {\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--simplebrack strong {\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--simplebrack em {\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--simplebrack em strong {\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--simplebrack del {\n \ +}\n \ +\n \ +/* 分隔线\n \ + * 粗细、样式和颜色\n \ + * border-top: 1px solid #3e3e3e;\n \ + */\n \ +.preview-theme--simplebrack hr {\n \ +}\n \ +\n \ +/* 图片\n \ + * 宽度 width: 80%;\n \ + * 居中 margin: 0 auto;\n \ + * 居左 margin: 0 0;\n \ + */\n \ +.preview-theme--simplebrack img {\n \ +box-shadow: rgba(170, 170, 170, 0.48) 0px 0px 6px 0px;\n \ +border-radius:4px;\n \ +margin-top:10px;\n \ +}\n \ +/* 行内代码 */\n \ +.preview-theme--simplebrack p code, .preview-theme--simplebrack li code {\n \ +color:#ff6441;\n \ +background-color: rgba(27,31,35,.05) !important;\n \ +}\n \ +\n \ +/* 非微信代码块\n \ + * 代码块不换行 display: -webkit-box !important;\n \ + * 代码块换行 display: block;\n \ + */\n \ +.preview-theme--simplebrack pre.custom {\n \ +box-shadow: rgba(170, 170, 170, 0.48) 0px 0px 6px 0px;\n \ +max-width: 100%;\n \ +border-radius:4px;\n \ +margin: 10px auto 0 auto;\n \ +}\n \ +.preview-theme--simplebrack pre code {\n \ +}\n \ +\n \ +/*\n \ + * 表格内的单元格\n \ + * 字体大小 font-size: 16px;\n \ + * 边框 border: 1px solid #ccc;\n \ + * 内边距 padding: 5px 10px;\n \ + */\n \ +.preview-theme--simplebrack table tr th,\n \ +.preview-theme--simplebrack table tr td {\n \ +font-size:14px;\n \ +}\n \ +\n \ +/* 脚注文字 */\n \ +.preview-theme--simplebrack .footnote-word {\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--simplebrack .footnote-ref {\n \ +}\n \ +\n \ +/* \"参考资料\"四个字 \n \ + * 内容 content: \"参考资料\";\n \ + */\n \ +.preview-theme--simplebrack .footnotes-sep {\n \ +font-size: 14px;\n \ +color: #888;\n \ +border-top: 1px solid #eee;\n \ +padding: 30px 0 10px 0px;\n \ +background-color: transparent;\n \ +margin-bottom: 20px;\n \ +width: 100%;\n \ +}\n \ +.preview-theme--simplebrack .footnotes-sep:before {\n \ +content:'参考资料';\n \ +}\n \ +.preview-theme--simplebrack .footnotes{\n \ +border-left:5px solid #eee;\n \ +padding-left:10px;\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--simplebrack .footnote-num {\n \ +font-size:14px;\n \ +color:#999;\n \ +}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--simplebrack .footnote-item p { \n \ +font-size:14px;\n \ +color:#999;\n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--simplebrack .footnote-item p em {\n \ +font-size:14px;\n \ +color:#999;\n \ +}\n \ +\n \ +/* 行间公式\n \ + * 最大宽度 max-width: 300% !important;\n \ + */\n \ +.preview-theme--simplebrack .block-equation svg {\n \ +}\n \ +\n \ +/* 行内公式\n \ + */\n \ +.preview-theme--simplebrack .inline-equation svg {\n \ +}\n \ +/* 文章结尾 */\n \ +.preview-theme--simplebrack:after{\n \ +content:'- END -';\n \ +font-size:15px;\n \ +display:block;\n \ +text-align:center;\n \ +margin-top:50px;\n \ +color:#999;\n \ +border-bottom:1px solid #eee;\n \ +}\n \ +\n \ +/*滑动幻灯片*/\n \ +.preview-theme--simplebrack .imageflow-layer1 img{\n \ +margin:0;\n \ +box-shadow: none;\n \ +border-radius: 0;\n \ +}\n \ +.preview-theme--simplebrack pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_simplebrack(); diff --git a/static/themes/preview-theme-yanqihu.js b/static/themes/preview-theme-yanqihu.js new file mode 100644 index 0000000..d12163a --- /dev/null +++ b/static/themes/preview-theme-yanqihu.js @@ -0,0 +1,305 @@ +function init_preview_theme_yanqihu() { +const style = document.createElement('style'); +style.id = 'preview-theme-yanqihu'; +style.type = 'text/css'; +style.innerHTML = "/* 雁栖湖 yanqihu\n \ +*/\n \ +.preview-theme--yanqihu {\n \ + color: #2b2b2b;\n \ + background-color: #fff;\n \ + counter-reset: counterh1 counterh2 counterh3;\n \ +}\n \ +\n \ +/* 段落,下方未标注标签参数均同此处\n \ +*/\n \ +.preview-theme--yanqihu p {\n \ +}\n \ +\n \ +/* 一级标题 */\n \ +.preview-theme--yanqihu h1 {\n \ + line-height: 28px;\n \ + border-bottom: 1px solid rgb(37,132,181);\n \ +}\n \ +\n \ +.preview-theme--yanqihu h1:before {\n \ + background: rgb(37,132,181);\n \ + color: white;\n \ + counter-increment: counterh1;\n \ + content: 'Part'counter(counterh1); \n \ + padding: 2px 8px;\n \ +}\n \ +\n \ +/* 一级标题内容 */\n \ +.preview-theme--yanqihu h1 .content {\n \ + color: rgb(37,132,181);\n \ + margin-left: 8px;\n \ + font-size: 20px;\n \ +}\n \ +\n \ +/* 一级标题前缀 */\n \ +.preview-theme--yanqihu h1 .prefix {\n \ +}\n \ +\n \ +/* 一级标题后缀 */\n \ +.preview-theme--yanqihu h1 .suffix {\n \ +}\n \ +\n \ +/* 二级标题 */\n \ +.preview-theme--yanqihu h2 {\n \ +}\n \ +\n \ +/* 二级标题内容 */\n \ +.preview-theme--yanqihu h2 .content {\n \ + font-size: 18px;\n \ + border-bottom: 4px solid rgb(37,132,181);\n \ + padding: 2px 4px;\n \ + color: rgb(37,132,181);\n \ +}\n \ +\n \ +/* 二级标题前缀 */\n \ +.preview-theme--yanqihu h2 .prefix {\n \ + display: inline-block;\n \ +}\n \ +\n \ +.preview-theme--yanqihu h2 .prefix:before {\n \ + counter-increment: counterh2;\n \ + content: counter(counterh2); \n \ + color:rgb(159,205,208);\n \ + border-bottom: 4px solid rgb(159,205,208);\n \ + font-size: 18px;\n \ + padding: 2px 4px;\n \ +}\n \ +\n \ +/* 二级标题后缀 */\n \ +.preview-theme--yanqihu h2 .suffix {\n \ +}\n \ +\n \ +.preview-theme--yanqihu h1:after, .preview-theme--yanqihu h2:after {\n \ + border-bottom: unset;\n \ +}\n \ +\n \ +/* 三级标题 */\n \ +.preview-theme--yanqihu h3 {\n \ +}\n \ +\n \ +/* 三级标题内容 */\n \ +.preview-theme--yanqihu h3 .content {\n \ + font-size: 16px;\n \ + border-bottom: 1px solid rgb(37,132,181);\n \ + padding: 2px 10px;\n \ + color: rgb(37,132,181);\n \ +}\n \ +\n \ +/* 三级标题前缀 */\n \ +.preview-theme--yanqihu h3 .prefix {\n \ + display:inline-block;\n \ + background:linear-gradient(45deg, transparent 48%, rgb(37,132,181) 48%, \n \ + rgb(37,132,181) 52%, transparent 52%);\n \ + width:24px;\n \ + height:24px;\n \ + margin-bottom: -7px;\n \ +}\n \ +\n \ +/* 三级标题后缀 */\n \ +.preview-theme--yanqihu h3 .suffix {\n \ +}\n \ +\n \ +/* 无序列表整体样式\n \ +* list-style-type: square|circle|disc;\n \ +*/\n \ +.preview-theme--yanqihu ul {\n \ +}\n \ +\n \ +/* 有序列表整体样式\n \ +* list-style-type: upper-roman|lower-greek|lower-alpha;\n \ +*/\n \ +.preview-theme--yanqihu ol {\n \ +}\n \ +\n \ +/* 列表内容,不要设置li\n \ +*/\n \ +.preview-theme--yanqihu li section {\n \ +}\n \ +\n \ +/* 一级引用\n \ +* 左边缘颜色 border-left-color: black;\n \ +* 背景色 background: gray;\n \ +*/\n \ +.preview-theme--yanqihu blockquote {\n \ + color: rgba(0,0,0,0.5) !important;\n \ + border: 1px dashed rgb(37,132,181) !important;\n \ + background: transparent !important;\n \ +}\n \ +\n \ +/* 一级引用文字 */\n \ +.preview-theme--yanqihu blockquote p {\n \ +}\n \ +\n \ +/* 二级引用\n \ +*/\n \ +.preview-theme--yanqihu .multiquote-2 {\n \ + border: 1px dashed rgb(248,99,77);\n \ + box-shadow: none;\n \ +}\n \ +\n \ +.preview-theme--yanqihu .multiquote-2 blockquote {\n \ + margin: 0;\n \ +}\n \ +\n \ +/* 二级引用文字 */\n \ +.preview-theme--yanqihu .multiquote-2 p {\n \ +}\n \ +\n \ +.preview-theme--yanqihu .multiquote-2 strong {\n \ + color:rgb(248,99,77);\n \ +}\n \ +\n \ +.preview-theme--yanqihu .multiquote-2 a {\n \ + color:rgb(248,99,77);\n \ + border-bottom: 1px solid rgb(248,99,77);\n \ +}\n \ +\n \ +/* 三级引用\n \ +*/\n \ +.preview-theme--yanqihu .multiquote-3 {\n \ +}\n \ +\n \ +/* 三级引用文字 */\n \ +.preview-theme--yanqihu .multiquote-3 p {\n \ +}\n \ +\n \ +/* 链接 \n \ +* border-bottom: 1px solid #009688;\n \ +*/\n \ +.preview-theme--yanqihu a {\n \ + color:rgb(37,132,181);\n \ + border-bottom: 1px solid rgb(37,132,181);\n \ +}\n \ +\n \ +/* 加粗 */\n \ +.preview-theme--yanqihu strong {\n \ + color: rgb(37,132,181);\n \ +}\n \ +\n \ +/* 斜体 */\n \ +.preview-theme--yanqihu em {\n \ + color: rgb(37,132,181);\n \ +}\n \ +\n \ +/* 加粗斜体 */\n \ +.preview-theme--yanqihu em strong {\n \ + color: rgb(37,132,181);\n \ +}\n \ +\n \ +/* 删除线 */\n \ +.preview-theme--yanqihu del {\n \ +}\n \ +\n \ +/* 分隔线\n \ +* 粗细、样式和颜色\n \ +* border-top: 1px solid #3e3e3e;\n \ +*/\n \ +.preview-theme--yanqihu hr {\n \ + border-top: 1px solid rgb(37,132,181);\n \ +}\n \ +\n \ +/* 图片\n \ +* 宽度 width: 80%;\n \ +* 居中 margin: 0 auto;\n \ +* 居左 margin: 0 0;\n \ +*/\n \ +.preview-theme--yanqihu img {\n \ +}\n \ +\n \ +/* 图片描述文字 */\n \ +.preview-theme--yanqihu figcaption {\n \ +}\n \ +\n \ +/* 行内代码 */\n \ +.preview-theme--yanqihu p code,\n \ +.preview-theme--yanqihu li code,\n \ +.preview-theme--yanqihu table code {\n \ + background-color: rgba(0,0,0,.05);\n \ +}\n \ +\n \ +/* \n \ +* 代码块不换行 display: -webkit-box !important;\n \ +* 代码块换行 display: block;\n \ +*/\n \ +.preview-theme--yanqihu pre code {\n \ +}\n \ +\n \ +/*\n \ +* 表格内的单元格\n \ +* 字体大小 font-size: 16px;\n \ +* 边框 border: 1px solid #ccc;\n \ +* 内边距 padding: 5px 10px;\n \ +*/\n \ +.preview-theme--yanqihu table tr th {\n \ + border: 1px solid rgb(248,99,77);\n \ + background-color: rgb(235,114, 80);\n \ + color: #f8f8f8;\n \ + border-bottom: 0;\n \ + border: 1px solid rgb(245,203,174);\n \ +}\n \ +\n \ +.preview-theme--yanqihu table tr td {\n \ + border: 1px solid rgb(245,203,174);\n \ +}\n \ +/* \n \ +* 某一列表格列宽控制\n \ +* n 可以修改为具体数字,不修改时表示所有列\n \ +* 最小列宽 min-width: 85px;\n \ +*/\n \ +.preview-theme--yanqihu table tr th:nth-of-type(n),\n \ +.preview-theme--yanqihu table tr td:nth-of-type(n){\n \ +}\n \ +\n \ +.preview-theme--yanqihu table tr:nth-of-type(2n) {\n \ + background-color: rgb(248,222,203);\n \ +}\n \ +/* 脚注文字 */\n \ +.preview-theme--yanqihu .footnote-word {\n \ + color:rgb(37,132,181);\n \ +}\n \ +\n \ +/* 脚注上标 */\n \ +.preview-theme--yanqihu .footnote-ref {\n \ + color:rgb(37,132,181);\n \ +}\n \ +\n \ +/* \"参考资料\"四个字 \n \ +* 内容 content: \"参考资料\";\n \ +*/\n \ +.preview-theme--yanqihu .footnotes-sep:before {\n \ +}\n \ +\n \ +/* 参考资料编号 */\n \ +.preview-theme--yanqihu .footnote-num {\n \ +}\n \ +\n \ +/* 参考资料文字 */\n \ +.preview-theme--yanqihu .footnote-item p { \n \ +}\n \ +\n \ +/* 参考资料解释 */\n \ +.preview-theme--yanqihu .footnote-item p em {\n \ +}\n \ +\n \ +/* 行间公式\n \ +* 最大宽度 max-width: 300% !important;\n \ +*/\n \ +.preview-theme--yanqihu .block-equation svg {\n \ +}\n \ +\n \ +/* 行内公式\n \ +*/\n \ +.preview-theme--yanqihu .inline-equation svg { \n \ +}\n \ +.preview-theme--yanqihu pre>code {\n \ +background-color: #333;\n \ +color: rgba(255,255,255,0.75);\n \ +}"; +document.head.appendChild(style); +} +init_preview_theme_yanqihu(); diff --git a/test/unit/.eslintrc b/test/unit/.eslintrc new file mode 100644 index 0000000..9213c3f --- /dev/null +++ b/test/unit/.eslintrc @@ -0,0 +1,8 @@ +{ + "env": { + "jest": true + }, + "extends": [ + "../../.eslintrc.js" + ] +} diff --git a/test/unit/jest.conf.js b/test/unit/jest.conf.js new file mode 100644 index 0000000..1420d11 --- /dev/null +++ b/test/unit/jest.conf.js @@ -0,0 +1,33 @@ +const path = require('path'); + +module.exports = { + rootDir: path.resolve(__dirname, '../../'), + moduleFileExtensions: [ + 'js', + 'json', + 'vue', + ], + moduleNameMapper: { + '\\.(css|scss)$': 'identity-obj-proxy', + '^!raw-loader!': 'identity-obj-proxy', + '^worker-loader!\\./templateWorker\\.js$': '+ +https://stackedit.cn/ +weekly +1.0 ++ +https://stackedit.cn/app +weekly +1.0 ++ +https://gitee.com/mafgwo/stackedit/issues +weekly +0.8 ++ +https://stackedit.cn/privacy_policy.html +monthly +0.6 +/test/unit/mocks/templateWorkerMock', + }, + transform: { + '^.+\\.js$': ' /node_modules/babel-jest', + '.*\\.(vue)$': ' /node_modules/vue-jest', + '.*\\.(yml|html|md)$': 'jest-raw-loader', + }, + snapshotSerializers: [' /node_modules/jest-serializer-vue'], + setupFiles: [ + ' /test/unit/setup', + ], + coverageDirectory: ' /test/unit/coverage', + collectCoverageFrom: [ + 'src/**/*.{js,vue}', + '!src/main.js', + '!**/node_modules/**', + ], + globals: { + NODE_ENV: 'production', + }, +}; diff --git a/test/unit/mocks/cryptoMock.js b/test/unit/mocks/cryptoMock.js new file mode 100644 index 0000000..c4d8a92 --- /dev/null +++ b/test/unit/mocks/cryptoMock.js @@ -0,0 +1,7 @@ +window.crypto = { + getRandomValues(array) { + for (let i = 0; i < array.length; i += 1) { + array[i] = Math.floor(Math.random() * 1000000); + } + }, +}; diff --git a/test/unit/mocks/localStorageMock.js b/test/unit/mocks/localStorageMock.js new file mode 100644 index 0000000..4247a6e --- /dev/null +++ b/test/unit/mocks/localStorageMock.js @@ -0,0 +1,9 @@ +const store = {}; +window.localStorage = { + getItem(key) { + return store[key] || null; + }, + setItem(key, value) { + store[key] = value.toString(); + }, +}; diff --git a/test/unit/mocks/mutationObserverMock.js b/test/unit/mocks/mutationObserverMock.js new file mode 100644 index 0000000..6d55983 --- /dev/null +++ b/test/unit/mocks/mutationObserverMock.js @@ -0,0 +1,6 @@ +/* eslint-disable class-methods-use-this */ +class MutationObserver { + observe() { + } +} +window.MutationObserver = MutationObserver; diff --git a/test/unit/mocks/templateWorkerMock.js b/test/unit/mocks/templateWorkerMock.js new file mode 100644 index 0000000..86059f3 --- /dev/null +++ b/test/unit/mocks/templateWorkerMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/test/unit/setup.js b/test/unit/setup.js new file mode 100644 index 0000000..b25a35e --- /dev/null +++ b/test/unit/setup.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; +import './mocks/cryptoMock'; +import './mocks/mutationObserverMock'; + +Vue.config.productionTip = false; diff --git a/test/unit/specs/components/ButtonBar.spec.js b/test/unit/specs/components/ButtonBar.spec.js new file mode 100644 index 0000000..a8900ee --- /dev/null +++ b/test/unit/specs/components/ButtonBar.spec.js @@ -0,0 +1,47 @@ +import ButtonBar from '../../../../src/components/ButtonBar'; +import store from '../../../../src/store'; +import specUtils from '../specUtils'; + +describe('ButtonBar.vue', () => { + it('should toggle the navigation bar', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].showNavigationBar, + 'toggleNavigationBar', + )); + + it('should toggle the side preview', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].showSidePreview, + 'toggleSidePreview', + )); + + it('should toggle the editor', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].showEditor, + 'toggleEditor', + )); + + it('should toggle the focus mode', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].focusMode, + 'toggleFocusMode', + )); + + it('should toggle the scroll sync', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].scrollSync, + 'toggleScrollSync', + )); + + it('should toggle the status bar', async () => specUtils.checkToggler( + ButtonBar, + wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].showStatusBar, + 'toggleStatusBar', + )); +}); diff --git a/test/unit/specs/components/ContextMenu.spec.js b/test/unit/specs/components/ContextMenu.spec.js new file mode 100644 index 0000000..982e19e --- /dev/null +++ b/test/unit/specs/components/ContextMenu.spec.js @@ -0,0 +1,32 @@ +import { shallowMount } from '@vue/test-utils'; +import ContextMenu from '../../../../src/components/ContextMenu'; +import store from '../../../../src/store'; +import '../specUtils'; + +const mount = () => shallowMount(ContextMenu, { store }); + +describe('ContextMenu.vue', () => { + const name = 'Name'; + const makeOptions = () => ({ + coordinates: { + left: 0, + top: 0, + }, + items: [{ name }], + }); + + it('should open/close itself', async () => { + const wrapper = mount(); + expect(wrapper.contains('.context-menu__item')).toEqual(false); + setTimeout(() => wrapper.find('.context-menu__item').trigger('click'), 1); + const item = await store.dispatch('contextMenu/open', makeOptions()); + expect(item.name).toEqual(name); + }); + + it('should cancel itself', async () => { + const wrapper = mount(); + setTimeout(() => wrapper.trigger('click'), 1); + const item = await store.dispatch('contextMenu/open', makeOptions()); + expect(item).toEqual(null); + }); +}); diff --git a/test/unit/specs/components/Explorer.spec.js b/test/unit/specs/components/Explorer.spec.js new file mode 100644 index 0000000..1cd1099 --- /dev/null +++ b/test/unit/specs/components/Explorer.spec.js @@ -0,0 +1,194 @@ +import { shallowMount } from '@vue/test-utils'; +import Explorer from '../../../../src/components/Explorer'; +import store from '../../../../src/store'; +import workspaceSvc from '../../../../src/services/workspaceSvc'; +import specUtils from '../specUtils'; + +const mount = () => shallowMount(Explorer, { store }); +const select = (id) => { + store.commit('explorer/setSelectedId', id); + expect(store.getters['explorer/selectedNode'].item.id).toEqual(id); +}; +const ensureExists = file => expect(store.getters.allItemsById).toHaveProperty(file.id); +const ensureNotExists = file => expect(store.getters.allItemsById).not.toHaveProperty(file.id); +const refreshItem = item => store.getters.allItemsById[item.id]; + +describe('Explorer.vue', () => { + it('should create new file in the root folder', async () => { + expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); + const wrapper = mount(); + wrapper.find('.side-title__button--new-file').trigger('click'); + expect(store.state.explorer.newChildNode.isNil).toBeFalsy(); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'file', + parentId: null, + }); + }); + + it('should create new file in a folder', async () => { + const folder = await workspaceSvc.storeItem({ type: 'folder' }); + const wrapper = mount(); + select(folder.id); + wrapper.find('.side-title__button--new-file').trigger('click'); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'file', + parentId: folder.id, + }); + }); + + it('should not create new files in the trash folder', async () => { + const wrapper = mount(); + select('trash'); + wrapper.find('.side-title__button--new-file').trigger('click'); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'file', + parentId: null, + }); + }); + + it('should create new folders in the root folder', async () => { + expect(store.state.explorer.newChildNode.isNil).toBeTruthy(); + const wrapper = mount(); + wrapper.find('.side-title__button--new-folder').trigger('click'); + expect(store.state.explorer.newChildNode.isNil).toBeFalsy(); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'folder', + parentId: null, + }); + }); + + it('should create new folders in a folder', async () => { + const folder = await workspaceSvc.storeItem({ type: 'folder' }); + const wrapper = mount(); + select(folder.id); + wrapper.find('.side-title__button--new-folder').trigger('click'); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'folder', + parentId: folder.id, + }); + }); + + it('should not create new folders in the trash folder', async () => { + const wrapper = mount(); + select('trash'); + wrapper.find('.side-title__button--new-folder').trigger('click'); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'folder', + parentId: null, + }); + }); + + it('should not create new folders in the temp folder', async () => { + const wrapper = mount(); + select('temp'); + wrapper.find('.side-title__button--new-folder').trigger('click'); + expect(store.state.explorer.newChildNode.item).toMatchObject({ + type: 'folder', + parentId: null, + }); + }); + + it('should move file to the trash folder on delete', async () => { + const file = await workspaceSvc.createFile({}, true); + expect(file.parentId).toEqual(null); + const wrapper = mount(); + select(file.id); + wrapper.find('.side-title__button--delete').trigger('click'); + ensureExists(file); + expect(refreshItem(file).parentId).toEqual('trash'); + await specUtils.expectBadge('removeFile'); + }); + + it('should not delete the trash folder', async () => { + const wrapper = mount(); + select('trash'); + wrapper.find('.side-title__button--delete').trigger('click'); + await specUtils.resolveModal('trashDeletion'); + await specUtils.expectBadge('removeFile', false); + }); + + it('should not delete file in the trash folder', async () => { + const file = await workspaceSvc.createFile({ parentId: 'trash' }, true); + const wrapper = mount(); + select(file.id); + wrapper.find('.side-title__button--delete').trigger('click'); + await specUtils.resolveModal('trashDeletion'); + ensureExists(file); + await specUtils.expectBadge('removeFile', false); + }); + + it('should delete the temp folder after confirmation', async () => { + const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); + const wrapper = mount(); + select('temp'); + wrapper.find('.side-title__button--delete').trigger('click'); + await specUtils.resolveModal('tempFolderDeletion'); + ensureNotExists(file); + await specUtils.expectBadge('removeFolder'); + }); + + it('should delete temp file after confirmation', async () => { + const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); + const wrapper = mount(); + select(file.id); + wrapper.find('.side-title__button--delete').trigger('click'); + ensureExists(file); + await specUtils.resolveModal('tempFileDeletion'); + ensureNotExists(file); + await specUtils.expectBadge('removeFile'); + }); + + it('should delete folder after confirmation', async () => { + const folder = await workspaceSvc.storeItem({ type: 'folder' }); + const file = await workspaceSvc.createFile({ parentId: folder.id }, true); + const wrapper = mount(); + select(folder.id); + wrapper.find('.side-title__button--delete').trigger('click'); + await specUtils.resolveModal('folderDeletion'); + ensureNotExists(folder); + // Make sure file has been moved to Trash + ensureExists(file); + expect(refreshItem(file).parentId).toEqual('trash'); + await specUtils.expectBadge('removeFolder'); + }); + + it('should rename file', async () => { + const file = await workspaceSvc.createFile({}, true); + const wrapper = mount(); + select(file.id); + wrapper.find('.side-title__button--rename').trigger('click'); + expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id); + }); + + it('should rename folder', async () => { + const folder = await workspaceSvc.storeItem({ type: 'folder' }); + const wrapper = mount(); + select(folder.id); + wrapper.find('.side-title__button--rename').trigger('click'); + expect(store.getters['explorer/editingNode'].item.id).toEqual(folder.id); + }); + + it('should not rename the trash folder', async () => { + const wrapper = mount(); + select('trash'); + wrapper.find('.side-title__button--rename').trigger('click'); + expect(store.getters['explorer/editingNode'].isNil).toBeTruthy(); + }); + + it('should not rename the temp folder', async () => { + const wrapper = mount(); + select('temp'); + wrapper.find('.side-title__button--rename').trigger('click'); + expect(store.getters['explorer/editingNode'].isNil).toBeTruthy(); + }); + + it('should close itself', async () => { + store.dispatch('data/toggleExplorer', true); + specUtils.checkToggler( + Explorer, + wrapper => wrapper.find('.side-title__button--close').trigger('click'), + () => store.getters['data/layoutSettings'].showExplorer, + 'toggleExplorer', + ); + }); +}); diff --git a/test/unit/specs/components/ExplorerNode.spec.js b/test/unit/specs/components/ExplorerNode.spec.js new file mode 100644 index 0000000..5793bfc --- /dev/null +++ b/test/unit/specs/components/ExplorerNode.spec.js @@ -0,0 +1,307 @@ +import { shallowMount } from '@vue/test-utils'; +import ExplorerNode from '../../../../src/components/ExplorerNode'; +import store from '../../../../src/store'; +import workspaceSvc from '../../../../src/services/workspaceSvc'; +import explorerSvc from '../../../../src/services/explorerSvc'; +import specUtils from '../specUtils'; + +const makeFileNode = async () => { + const file = await workspaceSvc.createFile({}, true); + const node = store.getters['explorer/nodeMap'][file.id]; + expect(node.item.id).toEqual(file.id); + return node; +}; + +const makeFolderNode = async () => { + const folder = await workspaceSvc.storeItem({ type: 'folder' }); + const node = store.getters['explorer/nodeMap'][folder.id]; + expect(node.item.id).toEqual(folder.id); + return node; +}; + +const mount = node => shallowMount(ExplorerNode, { + store, + propsData: { node, depth: 1 }, +}); +const mountAndSelect = (node) => { + const wrapper = mount(node); + wrapper.find('.explorer-node__item').trigger('click'); + expect(store.getters['explorer/selectedNode'].item.id).toEqual(node.item.id); + expect(wrapper.classes()).toContain('explorer-node--selected'); + return wrapper; +}; + +const dragAndDrop = (sourceItem, targetItem) => { + const sourceNode = store.getters['explorer/nodeMap'][sourceItem.id]; + mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart', { + dataTransfer: { setData: () => {} }, + }); + expect(store.state.explorer.dragSourceId).toEqual(sourceItem.id); + const targetNode = store.getters['explorer/nodeMap'][targetItem.id]; + const wrapper = mount(targetNode); + wrapper.trigger('dragenter'); + expect(store.state.explorer.dragTargetId).toEqual(targetItem.id); + wrapper.trigger('drop'); + const expectedParentId = targetItem.type === 'file' ? targetItem.parentId : targetItem.id; + expect(store.getters['explorer/selectedNode'].item.parentId).toEqual(expectedParentId); +}; + +describe('ExplorerNode.vue', () => { + const modifiedName = 'Name'; + + it('should open file on select after a timeout', async () => { + const node = await makeFileNode(); + mountAndSelect(node); + expect(store.getters['file/current'].id).not.toEqual(node.item.id); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(store.getters['file/current'].id).toEqual(node.item.id); + await specUtils.expectBadge('switchFile'); + }); + + it('should not open already open file', async () => { + const node = await makeFileNode(); + store.commit('file/setCurrentId', node.item.id); + mountAndSelect(node); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(store.getters['file/current'].id).toEqual(node.item.id); + await specUtils.expectBadge('switchFile', false); + }); + + it('should open folder on select after a timeout', async () => { + const node = await makeFolderNode(); + const wrapper = mountAndSelect(node); + expect(wrapper.classes()).not.toContain('explorer-node--open'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(wrapper.classes()).toContain('explorer-node--open'); + }); + + it('should open folder on new child', async () => { + const node = await makeFolderNode(); + const wrapper = mountAndSelect(node); + // Close the folder + wrapper.find('.explorer-node__item').trigger('click'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(wrapper.classes()).not.toContain('explorer-node--open'); + explorerSvc.newItem(); + expect(wrapper.classes()).toContain('explorer-node--open'); + }); + + it('should create new file in a folder', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('New file'); + expect(wrapper.contains('.explorer-node__new-child')).toBe(true); + store.commit('explorer/setNewItemName', modifiedName); + wrapper.find('.explorer-node__new-child .text-input').trigger('blur'); + await new Promise(resolve => setTimeout(resolve, 1)); + expect(store.getters['explorer/selectedNode'].item).toMatchObject({ + name: modifiedName, + type: 'file', + parentId: node.item.id, + }); + expect(wrapper.contains('.explorer-node__new-child')).toBe(false); + await specUtils.expectBadge('createFile'); + }); + + it('should cancel file creation on escape', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('New file'); + expect(wrapper.contains('.explorer-node__new-child')).toBe(true); + store.commit('explorer/setNewItemName', modifiedName); + wrapper.find('.explorer-node__new-child .text-input').trigger('keydown', { + keyCode: 27, + }); + await new Promise(resolve => setTimeout(resolve, 1)); + expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({ + name: 'modifiedName', + type: 'file', + parentId: node.item.id, + }); + expect(wrapper.contains('.explorer-node__new-child')).toBe(false); + await specUtils.expectBadge('createFile', false); + }); + + it('should not create new file in a file', async () => { + const node = await makeFileNode(); + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); + }); + + it('should not create new file in the trash folder', async () => { + const node = store.getters['explorer/nodeMap'].trash; + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('New file').disabled).toBe(true); + }); + + it('should create new folder in folder', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('New folder'); + expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true); + store.commit('explorer/setNewItemName', modifiedName); + wrapper.find('.explorer-node__new-child--folder .text-input').trigger('blur'); + await new Promise(resolve => setTimeout(resolve, 1)); + expect(store.getters['explorer/selectedNode'].item).toMatchObject({ + name: modifiedName, + type: 'folder', + parentId: node.item.id, + }); + expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); + await specUtils.expectBadge('createFolder'); + }); + + it('should cancel folder creation on escape', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('New folder'); + expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true); + store.commit('explorer/setNewItemName', modifiedName); + wrapper.find('.explorer-node__new-child--folder .text-input').trigger('keydown', { + keyCode: 27, + }); + await new Promise(resolve => setTimeout(resolve, 1)); + expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({ + name: modifiedName, + type: 'folder', + parentId: node.item.id, + }); + expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false); + await specUtils.expectBadge('createFolder', false); + }); + + it('should not create new folder in a file', async () => { + const node = await makeFileNode(); + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); + }); + + it('should not create new folder in the trash folder', async () => { + const node = store.getters['explorer/nodeMap'].trash; + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); + }); + + it('should not create new folder in the temp folder', async () => { + const node = store.getters['explorer/nodeMap'].temp; + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true); + }); + + it('should rename file', async () => { + const node = await makeFileNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('Rename'); + expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); + wrapper.setData({ editingValue: modifiedName }); + wrapper.find('.explorer-node__item-editor .text-input').trigger('blur'); + expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName); + await specUtils.expectBadge('renameFile'); + }); + + it('should cancel rename file on escape', async () => { + const node = await makeFileNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('Rename'); + expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); + wrapper.setData({ editingValue: modifiedName }); + wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', { + keyCode: 27, + }); + expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName); + await specUtils.expectBadge('renameFile', false); + }); + + it('should rename folder', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('Rename'); + expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); + wrapper.setData({ editingValue: modifiedName }); + wrapper.find('.explorer-node__item-editor .text-input').trigger('blur'); + expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName); + await specUtils.expectBadge('renameFolder'); + }); + + it('should cancel rename folder on escape', async () => { + const node = await makeFolderNode(); + const wrapper = mount(node); + wrapper.trigger('contextmenu'); + await specUtils.resolveContextMenu('Rename'); + expect(wrapper.contains('.explorer-node__item-editor')).toBe(true); + wrapper.setData({ editingValue: modifiedName }); + wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', { + keyCode: 27, + }); + expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName); + await specUtils.expectBadge('renameFolder', false); + }); + + it('should not rename the trash folder', async () => { + const node = store.getters['explorer/nodeMap'].trash; + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true); + }); + + it('should not rename the temp folder', async () => { + const node = store.getters['explorer/nodeMap'].temp; + mount(node).trigger('contextmenu'); + expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true); + }); + + it('should move file into a folder', async () => { + const sourceItem = await workspaceSvc.createFile({}, true); + const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); + dragAndDrop(sourceItem, targetItem); + await specUtils.expectBadge('moveFile'); + }); + + it('should move folder into a folder', async () => { + const sourceItem = await workspaceSvc.storeItem({ type: 'folder' }); + const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); + dragAndDrop(sourceItem, targetItem); + await specUtils.expectBadge('moveFolder'); + }); + + it('should move file into a file parent folder', async () => { + const targetItem = await workspaceSvc.storeItem({ type: 'folder' }); + const file = await workspaceSvc.createFile({ parentId: targetItem.id }, true); + const sourceItem = await workspaceSvc.createFile({}, true); + dragAndDrop(sourceItem, file); + await specUtils.expectBadge('moveFile'); + }); + + it('should not move the trash folder', async () => { + const sourceNode = store.getters['explorer/nodeMap'].trash; + mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart'); + expect(store.state.explorer.dragSourceId).not.toEqual('trash'); + }); + + it('should not move the temp folder', async () => { + const sourceNode = store.getters['explorer/nodeMap'].temp; + mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart'); + expect(store.state.explorer.dragSourceId).not.toEqual('temp'); + }); + + it('should not move file to the temp folder', async () => { + const targetNode = store.getters['explorer/nodeMap'].temp; + const wrapper = mount(targetNode); + wrapper.trigger('dragenter'); + expect(store.state.explorer.dragTargetId).not.toEqual('temp'); + }); + + it('should not move file to a file in the temp folder', async () => { + const file = await workspaceSvc.createFile({ parentId: 'temp' }, true); + const targetNode = store.getters['explorer/nodeMap'][file.id]; + const wrapper = mount(targetNode); + wrapper.trigger('dragenter'); + expect(store.state.explorer.dragTargetId).not.toEqual(file.id); + }); +}); diff --git a/test/unit/specs/components/NavigationBar.spec.js b/test/unit/specs/components/NavigationBar.spec.js new file mode 100644 index 0000000..5573df0 --- /dev/null +++ b/test/unit/specs/components/NavigationBar.spec.js @@ -0,0 +1,19 @@ +import NavigationBar from '../../../../src/components/NavigationBar'; +import store from '../../../../src/store'; +import specUtils from '../specUtils'; + +describe('NavigationBar.vue', () => { + it('should toggle the explorer', async () => specUtils.checkToggler( + NavigationBar, + wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'), + () => store.getters['data/layoutSettings'].showExplorer, + 'toggleExplorer', + )); + + it('should toggle the side bar', async () => specUtils.checkToggler( + NavigationBar, + wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'), + () => store.getters['data/layoutSettings'].showSideBar, + 'toggleSideBar', + )); +}); diff --git a/test/unit/specs/components/Notification.spec.js b/test/unit/specs/components/Notification.spec.js new file mode 100644 index 0000000..4ac3cf3 --- /dev/null +++ b/test/unit/specs/components/Notification.spec.js @@ -0,0 +1,38 @@ +import { shallowMount } from '@vue/test-utils'; +import Notification from '../../../../src/components/Notification'; +import store from '../../../../src/store'; +import '../specUtils'; + +const mount = () => shallowMount(Notification, { store }); + +describe('Notification.vue', () => { + it('should autoclose itself', async () => { + const wrapper = mount(); + expect(wrapper.contains('.notification__item')).toBe(false); + store.dispatch('notification/showItem', { + type: 'info', + content: 'Test', + timeout: 10, + }); + expect(wrapper.contains('.notification__item')).toBe(true); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(wrapper.contains('.notification__item')).toBe(false); + }); + + it('should show messages from top to bottom', async () => { + const wrapper = mount(); + store.dispatch('notification/info', 'Test 1'); + store.dispatch('notification/info', 'Test 2'); + const items = wrapper.findAll('.notification__item'); + expect(items.length).toEqual(2); + expect(items.at(0).text()).toMatch(/Test 1/); + expect(items.at(1).text()).toMatch(/Test 2/); + }); + + it('should not open the same message twice', async () => { + const wrapper = mount(); + store.dispatch('notification/info', 'Test'); + store.dispatch('notification/info', 'Test'); + expect(wrapper.findAll('.notification__item').length).toEqual(1); + }); +}); diff --git a/test/unit/specs/specUtils.js b/test/unit/specs/specUtils.js new file mode 100644 index 0000000..fbf308c --- /dev/null +++ b/test/unit/specs/specUtils.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; +import store from '../../../src/store'; +import utils from '../../../src/services/utils'; +import '../../../src/icons'; +import '../../../src/components/common/vueGlobals'; + +const clone = object => JSON.parse(JSON.stringify(object)); + +const deepAssign = (target, origin) => { + Object.entries(origin).forEach(([key, value]) => { + const type = Object.prototype.toString.call(value); + if (type === '[object Object]' && Object.keys(value).length) { + deepAssign(target[key], value); + } else { + target[key] = value; + } + }); +}; + +const freshState = clone(store.state); + +beforeEach(() => { + // Restore store state before each test + deepAssign(store.state, clone(freshState)); +}); + +export default { + async checkToggler(Component, toggler, checker, featureId) { + const wrapper = shallowMount(Component, { store }); + const valueBefore = checker(); + toggler(wrapper); + const valueAfter = checker(); + expect(valueAfter).toEqual(!valueBefore); + await this.expectBadge(featureId); + }, + async resolveModal(type) { + const config = store.getters['modal/config']; + expect(config).toBeTruthy(); + expect(config.type).toEqual(type); + config.resolve(); + await new Promise(resolve => setTimeout(resolve, 1)); + }, + getContextMenuItem(name) { + return utils.someResult(store.state.contextMenu.items, item => item.name === name && item); + }, + async resolveContextMenu(name) { + const item = this.getContextMenuItem(name); + expect(item).toBeTruthy(); + store.state.contextMenu.resolve(item); + await new Promise(resolve => setTimeout(resolve, 1)); + }, + async expectBadge(featureId, isEarned = true) { + await new Promise(resolve => setTimeout(resolve, 1)); + expect(store.getters['data/allBadges'].filter(badge => badge.featureId === featureId)[0]).toMatchObject({ + isEarned, + }); + }, +};