first commit

This commit is contained in:
Jane 2024-02-19 17:25:32 +08:00
commit d21f90672b
455 changed files with 67178 additions and 0 deletions

14
.babelrc Normal file
View File

@ -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"]
}
}
}

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
.git
dist
.history
images
docs
Dockerfile
README.md
build.sh

9
.editorconfig Normal file
View File

@ -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

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
build/*.js
config/*.js
src/libs/*.js

44
.eslintrc.js Normal file
View File

@ -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
}
}

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules/
dist/
.history
.idea
npm-debug.log*
.vscode
stackedit_v4
chrome-app/*.zip
/test/unit/coverage/

8
.postcssrc.js Normal file
View File

@ -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": {}
}
}

7
.stylelintrc Normal file
View File

@ -0,0 +1,7 @@
{
"processors": ["stylelint-processor-html"],
"extends": "stylelint-config-standard",
"rules": {
"no-empty-source": null
}
}

22
.travis.yml Normal file
View File

@ -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

201
LICENSE Normal file
View File

@ -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.

0
README.md Normal file
View File

35
build/build.js Normal file
View File

@ -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'
))
})
})

48
build/check-versions.js Normal file
View File

@ -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)
}
}

24
build/deploy.sh Normal file
View File

@ -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

9
build/dev-client.js Normal file
View File

@ -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()
}
})

94
build/dev-server.js Normal file
View File

@ -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()
}
}

71
build/utils.js Normal file
View File

@ -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
}

12
build/vue-loader.conf.js Normal file
View File

@ -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
})
}

109
build/webpack.base.conf.js Normal file
View File

@ -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)
})
]
}

35
build/webpack.dev.conf.js Normal file
View File

@ -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()
]
})

154
build/webpack.prod.conf.js Normal file
View File

@ -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

View File

@ -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
}
}),
]
}

22
chart/.helmignore Normal file
View File

@ -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/

5
chart/Chart.yaml Normal file
View File

@ -0,0 +1,5 @@
apiVersion: v1
appVersion: vSTACKEDIT_VERSION
description: In-browser Markdown editor
name: stackedit
version: STACKEDIT_VERSION

21
chart/templates/NOTES.txt Normal file
View File

@ -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 }}

View File

@ -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 -}}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

71
chart/values.yaml Normal file
View File

@ -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: {}

BIN
chrome-app/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
chrome-app/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

BIN
chrome-app/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
chrome-app/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

BIN
chrome-app/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
chrome-app/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

28
chrome-app/manifest.json Normal file
View File

@ -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"
]
}

18
config/dev.env.js Normal file
View File

@ -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"',
})

39
config/index.js Normal file
View File

@ -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
}
}

3
config/prod.env.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
NODE_ENV: '"production"'
}

20
gulpfile.js Normal file
View File

@ -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')))));

28
index.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>StackEdit中文版</title>
<link rel="canonical" href="https://stackedit.cn/app">
<meta name="description" content="免费开源功能全面的Markdown编辑器。">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<script>
var _hmt = _hmt || [];
</script>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
<script>
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?20a1e7a201b42702c49074c87a1f1035";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</body>
</html>

27
index.js Normal file
View File

@ -0,0 +1,27 @@
const env = require('./config/prod.env');
Object.keys(env).forEach((key) => {
if (!process.env[key]) {
process.env[key] = JSON.parse(env[key]);
}
});
const http = require('http');
const express = require('express');
const app = express();
require('./server')(app);
const port = parseInt(process.env.PORT || 8080, 10);
const httpServer = http.createServer(app);
httpServer.listen(port, null, () => {
console.log(`HTTP server started: http://localhost:${port}`);
});
// Handle graceful shutdown
process.on('SIGTERM', () => {
httpServer.close(() => {
process.exit(0);
});
});

22028
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

141
package.json Normal file
View File

@ -0,0 +1,141 @@
{
"name": "stackedit",
"version": "5.15.21",
"description": "免费, 开源, 功能齐全的 Markdown 编辑器",
"author": "Benoit Schweblin, 豆萁",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/mafgwo/stackedit/issues"
},
"main": "index.js",
"scripts": {
"postinstall": "gulp build-prism",
"start": "node build/dev-server.js",
"build": "node build/build.js && npm run build-style",
"build-style": "webpack --config build/webpack.style.conf.js",
"lint": "eslint --ext .js,.vue src server",
"unit": "jest --config test/unit/jest.conf.js --runInBand",
"unit-with-coverage": "jest --config test/unit/jest.conf.js --runInBand --coverage",
"test": "npm run lint && npm run unit",
"preversion": "npm run test",
"postversion": "git push origin master --tags && npm publish",
"patch": "npm version patch -m \"Tag v%s\"",
"minor": "npm version minor -m \"Tag v%s\"",
"major": "npm version major -m \"Tag v%s\"",
"chart": "mkdir -p dist && rm -rf dist/stackedit && cp -r chart dist/stackedit && sed -i.bak -e s/STACKEDIT_VERSION/$npm_package_version/g dist/stackedit/*.yaml && rm dist/stackedit/*.yaml.bak"
},
"dependencies": {
"@vue/test-utils": "^1.0.0-beta.16",
"abcjs": "^5.2.0",
"babel-runtime": "^6.26.0",
"bezier-easing": "^1.1.0",
"body-parser": "^1.18.2",
"clipboard": "^1.7.1",
"compression": "^1.7.0",
"diff-match-patch": "^1.0.0",
"file-saver": "^1.3.8",
"handlebars": "^4.0.10",
"indexeddbshim": "^3.6.2",
"js-yaml": "^3.11.0",
"katex": "^0.16.2",
"markdown-it": "^8.4.1",
"markdown-it-abbr": "^1.0.4",
"markdown-it-deflist": "^2.0.2",
"markdown-it-emoji": "^1.3.0",
"markdown-it-footnote": "^3.0.1",
"markdown-it-imsize": "^2.0.1",
"markdown-it-mark": "^2.0.0",
"markdown-it-pandoc-renderer": "1.1.3",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"mermaid": "^8.9.2",
"mousetrap": "^1.6.1",
"normalize-scss": "^7.0.1",
"prismjs": "^1.6.0",
"request": "^2.85.0",
"serve-static": "^1.13.2",
"tmp": "^0.0.33",
"turndown": "^7.1.1",
"vue": "^2.5.16",
"vuex": "^3.0.1"
},
"devDependencies": {
"autoprefixer": "^6.7.2",
"babel-core": "^6.26.3",
"babel-eslint": "^8.2.3",
"babel-jest": "^21.0.2",
"babel-loader": "^7.1.4",
"babel-plugin-dynamic-import-node": "^1.2.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-2": "^6.22.0",
"babel-register": "^6.22.0",
"chalk": "^1.1.3",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.5.1",
"css-loader": "^0.28.11",
"eslint": "^4.19.1",
"eslint-config-airbnb-base": "^12.1.0",
"eslint-friendly-formatter": "^4.0.1",
"eslint-import-resolver-webpack": "^0.9.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-html": "^4.0.3",
"eslint-plugin-import": "^2.11.0",
"eventsource-polyfill": "^0.9.6",
"express": "^4.16.3",
"extract-text-webpack-plugin": "^2.0.0",
"favicons-webpack-plugin": "^0.0.9",
"file-loader": "^1.1.11",
"friendly-errors-webpack-plugin": "^1.7.0",
"gulp": "^4.0.2",
"gulp-concat": "^2.6.1",
"html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.18.0",
"identity-obj-proxy": "^3.0.0",
"ignore-loader": "^0.1.2",
"jest": "^23.0.0",
"jest-raw-loader": "^1.0.1",
"jest-serializer-vue": "^0.3.0",
"js-md5": "^0.7.3",
"node-sass": "^4.0.0",
"npm-bump": "^0.0.23",
"offline-plugin": "^5.0.3",
"opn": "^4.0.2",
"optimize-css-assets-webpack-plugin": "^1.3.2",
"ora": "^1.2.0",
"raw-loader": "^0.5.1",
"replace-in-file": "^4.1.0",
"rimraf": "^2.6.0",
"sass-loader": "^7.0.1",
"semver": "^5.5.0",
"shelljs": "^0.8.1",
"string-replace-loader": "^2.1.1",
"stylelint": "^9.2.0",
"stylelint-config-standard": "^16.0.0",
"stylelint-processor-html": "^1.0.0",
"stylelint-webpack-plugin": "^0.10.4",
"url-loader": "^1.0.1",
"vue-jest": "^1.0.2",
"vue-loader": "^15.0.9",
"vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.16",
"webpack": "^2.6.1",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.18.0",
"webpack-merge": "^4.1.2",
"webpack-pwa-manifest": "^3.7.1",
"worker-loader": "^1.1.1"
},
"engines": {
"node": ">= 8.0.0",
"npm": ">= 5.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 10"
]
}

54
server/conf.js Normal file
View File

@ -0,0 +1,54 @@
const pandocPath = process.env.PANDOC_PATH || 'pandoc';
const wkhtmltopdfPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';
const paypalReceiverEmail = process.env.PAYPAL_RECEIVER_EMAIL;
const dropboxAppKey = process.env.DROPBOX_APP_KEY;
const dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL;
const githubClientId = process.env.GITHUB_CLIENT_ID;
const githubClientSecret = process.env.GITHUB_CLIENT_SECRET;
const giteeClientId = process.env.GITEE_CLIENT_ID;
const giteeClientSecret = process.env.GITEE_CLIENT_SECRET;
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleApiKey = process.env.GOOGLE_API_KEY;
const wordpressClientId = process.env.WORDPRESS_CLIENT_ID;
const giteaClientId = process.env.GITEA_CLIENT_ID;
const giteaClientSecret = process.env.GITEA_CLIENT_SECRET;
const giteaUrl = process.env.GITEA_URL;
const gitlabClientId = process.env.GITLAB_CLIENT_ID;
const gitlabClientSecret = process.env.GITLAB_CLIENT_SECRET;
const gitlabUrl = process.env.GITLAB_URL;
exports.values = {
pandocPath,
wkhtmltopdfPath,
paypalReceiverEmail,
dropboxAppKey,
dropboxAppKeyFull,
githubClientId,
githubClientSecret,
giteeClientId,
giteeClientSecret,
googleClientId,
googleApiKey,
wordpressClientId,
giteaClientId,
giteaClientSecret,
giteaUrl,
gitlabClientId,
gitlabClientSecret,
gitlabUrl,
};
exports.publicValues = {
dropboxAppKey,
dropboxAppKeyFull,
githubClientId,
googleClientId,
googleApiKey,
wordpressClientId,
allowSponsorship: !!paypalReceiverEmail,
giteaClientId,
giteaUrl,
gitlabClientId,
gitlabUrl,
};

40
server/gitea.js Normal file
View File

@ -0,0 +1,40 @@
const request = require('request');
const conf = require('./conf');
function giteaToken(queryParam) {
return new Promise((resolve, reject) => {
request({
method: 'POST',
url: `${conf.values.giteaUrl}/login/oauth/access_token`,
headers: {
'content-type': 'application/json',
},
json: true,
body: {
...queryParam,
client_id: conf.values.giteaClientId,
client_secret: conf.values.giteaClientSecret,
},
}, (err, res, body) => {
if (err) {
reject(err);
}
const token = body.access_token;
if (token) {
resolve(body);
} else {
reject(res.statusCode + ',body:' + JSON.stringify(body));
}
});
});
}
exports.giteaToken = (req, res) => {
giteaToken(req.query)
.then(
tokenBody => res.send(tokenBody),
err => res
.status(400)
.send(err ? err.message || err.toString() : 'bad_code'),
);
};

42
server/gitee.js Normal file
View File

@ -0,0 +1,42 @@
const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies
const request = require('request');
const conf = require('./conf');
function giteeToken(clientId, code, oauth2RedirectUri) {
const clientIndex = conf.values.giteeClientId.split(',').indexOf(clientId);
const clientSecret = conf.values.giteeClientSecret.split(',')[clientIndex];
return new Promise((resolve, reject) => {
request({
method: 'POST',
url: 'https://gitee.com/oauth/token',
form: {
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: oauth2RedirectUri,
},
json: true
}, (err, res, body) => {
if (err) {
reject(err);
}
const token = body.access_token;
if (token) {
resolve(body);
} else {
reject(res.statusCode + ',body:' + JSON.stringify(body));
}
});
});
}
exports.giteeToken = (req, res) => {
giteeToken(req.query.clientId, req.query.code, req.query.oauth2RedirectUri)
.then(
tokenBody => res.send(tokenBody),
err => res
.status(400)
.send(err ? err.message || err.toString() : 'bad_code'),
);
};

37
server/github.js Normal file
View File

@ -0,0 +1,37 @@
const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies
const request = require('request');
const conf = require('./conf');
function githubToken(clientId, code) {
return new Promise((resolve, reject) => {
request({
method: 'POST',
url: 'https://github.com/login/oauth/access_token',
qs: {
client_id: clientId,
client_secret: conf.values.githubClientSecret,
code,
},
}, (err, res, body) => {
if (err) {
reject(err);
}
const token = qs.parse(body).access_token;
if (token) {
resolve(token);
} else {
reject(res.statusCode);
}
});
});
}
exports.githubToken = (req, res) => {
githubToken(req.query.clientId, req.query.code)
.then(
token => res.send(token),
err => res
.status(400)
.send(err ? err.message || err.toString() : 'bad_code'),
);
};

40
server/gitlab.js Normal file
View File

@ -0,0 +1,40 @@
const request = require('request');
const conf = require('./conf');
function gitlabToken(queryParam) {
return new Promise((resolve, reject) => {
request({
method: 'POST',
url: `${conf.values.gitlabUrl}/oauth/token`,
headers: {
'content-type': 'application/json',
},
json: true,
qs: {
...queryParam,
client_id: conf.values.gitlabClientId,
client_secret: conf.values.gitlabClientSecret,
},
}, (err, res, body) => {
if (err) {
reject(err);
}
const token = body.access_token;
if (token) {
resolve(body);
} else {
reject(res.statusCode + ',body:' + JSON.stringify(body));
}
});
});
}
exports.gitlabToken = (req, res) => {
gitlabToken(req.query)
.then(
tokenBody => res.send(tokenBody),
err => res
.status(400)
.send(err ? err.message || err.toString() : 'bad_code'),
);
};

89
server/index.js Normal file
View File

@ -0,0 +1,89 @@
const compression = require('compression');
const serveStatic = require('serve-static');
const bodyParser = require('body-parser');
const path = require('path');
const github = require('./github');
const gitee = require('./gitee');
const gitea = require('./gitea');
const gitlab = require('./gitlab');
const pdf = require('./pdf');
const pandoc = require('./pandoc');
const conf = require('./conf');
const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);
module.exports = (app) => {
if (process.env.NODE_ENV === 'production') {
// Enable CORS for fonts
app.all('*', (req, res, next) => {
if (/\.(eot|ttf|woff2?|svg)$/.test(req.url)) {
res.header('Access-Control-Allow-Origin', '*');
}
next();
});
// Use gzip compression
app.use(compression());
}
app.get('/oauth2/githubToken', github.githubToken);
app.get('/oauth2/giteeToken', gitee.giteeToken);
app.get('/oauth2/giteaToken', gitea.giteaToken);
app.get('/oauth2/gitlabToken', gitlab.gitlabToken);
app.get('/conf', (req, res) => res.send(conf.publicValues));
app.post('/pdfExport', pdf.generate);
app.post('/pandocExport', pandoc.generate);
app.get('/giteeClientId', (req, res) => {
const giteeClientIds = conf.values.giteeClientId.split(',');
// 仅一个 则直接返回
if (giteeClientIds.length === 1) {
res.send(giteeClientIds[0]);
return;
}
// 是否随机一个clientId 默认第一个 如果random 为1 则使用随机 避免单个应用接口次数用满无法自动切换其他应用
const random = req.query.random;
if (!random) {
res.send(giteeClientIds[0]);
return;
}
// 随机一个 排除第一个 因为第一个应用接口次数用完了
const clientId = giteeClientIds[Math.floor(((giteeClientIds.length - 1) * Math.random())) + 1];
res.send(clientId);
});
// Serve landing.html
app.get('/', (req, res) => res.sendFile(resolvePath('static/landing/index.html')));
// Serve privacy_policy.html
app.get('/privacy_policy.html', (req, res) => res.sendFile(resolvePath('static/landing/privacy_policy.html')));
// Serve sitemap.xml
app.get('/sitemap.xml', (req, res) => res.sendFile(resolvePath('static/sitemap.xml')));
// Serve callback.html
app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html')));
// Google Drive action receiver
app.get('/googleDriveAction', (req, res) =>
res.redirect(`./app#providerId=googleDrive&state=${encodeURIComponent(req.query.state)}`));
// Serve the static folder with 30 day max-age
app.use('/themes', serveStatic(resolvePath('static/themes'), {
maxAge: '5d',
}));
// Serve style.css with 1 day max-age
app.get('/style.css', (req, res) => res.sendFile(resolvePath('dist/style.css'), {
maxAge: '1d',
}));
// Serve share.html
app.get('/share.html', (req, res) => res.sendFile(resolvePath('static/landing/share.html')));
app.get('/gistshare.html', (req, res) => res.sendFile(resolvePath('static/landing/gistshare.html')));
// Serve static resources
if (process.env.NODE_ENV === 'production') {
// Serve index.html in /app
app.get('/app', (req, res) => res.sendFile(resolvePath('dist/index.html')));
// Serve the static folder with 1 year max-age
app.use('/static', serveStatic(resolvePath('dist/static'), {
maxAge: '1y',
}));
app.use(serveStatic(resolvePath('dist')));
}
};

141
server/pandoc.js Normal file
View File

@ -0,0 +1,141 @@
/* global window */
const { spawn } = require('child_process');
const fs = require('fs');
const tmp = require('tmp');
const conf = require('./conf');
const outputFormats = {
asciidoc: 'text/plain',
context: 'application/x-latex',
epub: 'application/epub+zip',
epub3: 'application/epub+zip',
latex: 'application/x-latex',
odt: 'application/vnd.oasis.opendocument.text',
pdf: 'application/pdf',
rst: 'text/plain',
rtf: 'application/rtf',
textile: 'text/plain',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};
const highlightStyles = [
'pygments',
'kate',
'monochrome',
'espresso',
'zenburn',
'haddock',
'tango',
];
const readJson = (str) => {
try {
return JSON.parse(str);
} catch (e) {
return {};
}
};
exports.generate = (req, res) => {
let pandocError = '';
const outputFormat = Object.prototype.hasOwnProperty.call(outputFormats, req.query.format)
? req.query.format
: 'pdf';
new Promise((resolve, reject) => {
tmp.file({
postfix: `.${outputFormat}`,
}, (err, filePath, fd, cleanupCallback) => {
if (err) {
reject(err);
} else {
resolve({
filePath,
cleanupCallback,
});
}
});
}).then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => {
const options = readJson(req.query.options);
const metadata = readJson(req.query.metadata);
const params = [];
params.push('--pdf-engine=xelatex');
params.push('--webtex=http://chart.apis.google.com/chart?cht=tx&chf=bg,s,FFFFFF00&chco=000000&chl=');
if (options.toc) {
params.push('--toc');
}
options.tocDepth = parseInt(options.tocDepth, 10);
if (!Number.isNaN(options.tocDepth)) {
params.push('--toc-depth', options.tocDepth);
}
options.highlightStyle = highlightStyles.includes(options.highlightStyle) ? options.highlightStyle : 'kate';
params.push('--highlight-style', options.highlightStyle);
Object.keys(metadata).forEach((key) => {
params.push('-M', `${key}=${metadata[key]}`);
});
let finished = false;
function onError(error) {
finished = true;
cleanupCallback();
reject(error);
}
const format = outputFormat === 'pdf' ? 'latex' : outputFormat;
params.push('-f', 'json', '-t', format, '-o', filePath);
const pandoc = spawn(conf.values.pandocPath, params, {
stdio: [
'pipe',
'ignore',
'pipe',
],
});
let timeoutId = setTimeout(() => {
timeoutId = null;
pandoc.kill();
}, 50000);
pandoc.on('error', onError);
pandoc.stdin.on('error', onError);
pandoc.stderr.on('data', (data) => {
pandocError += `${data}`;
});
pandoc.on('close', (code) => {
if (!finished) {
clearTimeout(timeoutId);
if (!timeoutId) {
res.statusCode = 408;
cleanupCallback();
reject(new Error('timeout'));
} else if (code) {
cleanupCallback();
reject();
} else {
res.set('Content-Type', outputFormats[outputFormat]);
const readStream = fs.createReadStream(filePath);
readStream.on('open', () => readStream.pipe(res));
readStream.on('close', () => cleanupCallback());
readStream.on('error', () => {
cleanupCallback();
reject();
});
}
}
});
req.pipe(pandoc.stdin);
}))
.catch((err) => {
console.error(err);
const message = err && err.message;
if (message === 'unauthorized') {
res.statusCode = 401;
res.end('Unauthorized.');
} else if (message === 'timeout') {
res.statusCode = 408;
res.end('Request timeout.');
} else {
res.statusCode = 400;
res.end(pandocError || 'Unknown error.');
}
});
};

178
server/pdf.js Normal file
View File

@ -0,0 +1,178 @@
/* global window,MathJax */
const { spawn } = require('child_process');
const fs = require('fs');
const tmp = require('tmp');
const conf = require('./conf');
/* eslint-disable no-var, prefer-arrow-callback, func-names */
function waitForJavaScript() {
if (window.MathJax) {
// Amazon EC2: fix TeX font detection
MathJax.Hub.Register.StartupHook('HTML-CSS Jax Startup', function () {
var htmlCss = MathJax.OutputJax['HTML-CSS'];
htmlCss.Font.checkWebFont = function (check, font, callback) {
if (check.time(callback)) {
return;
}
if (check.total === 0) {
htmlCss.Font.testFont(font);
setTimeout(check, 200);
} else {
callback(check.STATUS.OK);
}
};
});
MathJax.Hub.Queue(function () {
window.status = 'done';
});
} else {
setTimeout(function () {
window.status = 'done';
}, 2000);
}
}
/* eslint-disable no-var, prefer-arrow-callback, func-names */
const authorizedPageSizes = [
'A3',
'A4',
'Legal',
'Letter',
];
const readJson = (str) => {
try {
return JSON.parse(str);
} catch (e) {
return {};
}
};
exports.generate = (req, res) => {
let wkhtmltopdfError = '';
new Promise((resolve, reject) => {
tmp.file((err, filePath, fd, cleanupCallback) => {
if (err) {
reject(err);
} else {
resolve({
filePath,
cleanupCallback,
});
}
});
}).then(({ filePath, cleanupCallback }) => new Promise((resolve, reject) => {
let finished = false;
function onError(err) {
finished = true;
cleanupCallback();
reject(err);
}
const options = readJson(req.query.options);
const params = [];
// Margins
const marginTop = parseInt(`${options.marginTop}`, 10);
params.push('-T', Number.isNaN(marginTop) ? 25 : marginTop);
const marginRight = parseInt(`${options.marginRight}`, 10);
params.push('-R', Number.isNaN(marginRight) ? 25 : marginRight);
const marginBottom = parseInt(`${options.marginBottom}`, 10);
params.push('-B', Number.isNaN(marginBottom) ? 25 : marginBottom);
const marginLeft = parseInt(`${options.marginLeft}`, 10);
params.push('-L', Number.isNaN(marginLeft) ? 25 : marginLeft);
// Header
if (options.headerCenter) {
params.push('--header-center', `${options.headerCenter}`);
}
if (options.headerLeft) {
params.push('--header-left', `${options.headerLeft}`);
}
if (options.headerRight) {
params.push('--header-right', `${options.headerRight}`);
}
if (options.headerFontName) {
params.push('--header-font-name', `${options.headerFontName}`);
}
if (options.headerFontSize) {
params.push('--header-font-size', `${options.headerFontSize}`);
}
// Footer
if (options.footerCenter) {
params.push('--footer-center', `${options.footerCenter}`);
}
if (options.footerLeft) {
params.push('--footer-left', `${options.footerLeft}`);
}
if (options.footerRight) {
params.push('--footer-right', `${options.footerRight}`);
}
if (options.footerFontName) {
params.push('--footer-font-name', `${options.footerFontName}`);
}
if (options.footerFontSize) {
params.push('--footer-font-size', `${options.footerFontSize}`);
}
// Page size
params.push('--page-size', !authorizedPageSizes.includes(options.pageSize) ? 'A4' : options.pageSize);
// Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason
params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`);
params.push('--window-status', 'done');
const wkhtmltopdf = spawn(conf.values.wkhtmltopdfPath, params.concat('-', filePath), {
stdio: [
'pipe',
'ignore',
'pipe',
],
});
let timeoutId = setTimeout(function () {
timeoutId = null;
wkhtmltopdf.kill();
}, 50000);
wkhtmltopdf.on('error', onError);
wkhtmltopdf.stdin.on('error', onError);
wkhtmltopdf.stderr.on('data', (data) => {
wkhtmltopdfError += `${data}`;
});
wkhtmltopdf.on('close', (code) => {
if (!finished) {
clearTimeout(timeoutId);
if (!timeoutId) {
cleanupCallback();
reject(new Error('timeout'));
} else if (code) {
cleanupCallback();
reject();
} else {
res.set('Content-Type', 'application/pdf');
const readStream = fs.createReadStream(filePath);
readStream.on('open', () => readStream.pipe(res));
readStream.on('close', () => cleanupCallback());
readStream.on('error', () => {
cleanupCallback();
reject();
});
}
}
});
req.pipe(wkhtmltopdf.stdin);
}))
.catch((err) => {
console.error(err);
const message = err && err.message;
if (message === 'unauthorized') {
res.statusCode = 401;
res.end('Unauthorized.');
} else if (message === 'timeout') {
res.statusCode = 408;
res.end('Request timeout.');
} else {
res.statusCode = 400;
res.end(wkhtmltopdfError || 'Unknown error.');
}
});
};

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><path d="M20.512 178.499c-3.359-.884-6.258-2.184-8.931-4.006-2.257-1.538-5.556-4.717-6.811-6.563-1.532-2.255-3.293-6.117-4.011-8.795-.732-2.732-.743-3.82-.757-69.395-.013-65.245.002-66.679.72-69.483C3.259 10.341 11.117 2.797 21.251.546c2.914-.647 133.08-.76 136.223-.118 8.509 1.738 15.198 6.846 19.068 14.564 3.078 6.135 2.803-.617 2.943 72.231.09 46.349.007 65.808-.288 68.232-1.386 11.345-9.211 20.143-20.471 23.019-2.88.735-3.882.746-69.275.726-63.227-.019-66.474-.052-68.939-.701z" fill="#f57d00"/><path d="M115.162 144.835c8.064-1.1 14.384-4.333 20.313-10.39 4.289-4.382 6.974-9.125 8.728-15.419.729-2.615.79-3.888.924-19.242.101-11.588.017-17.015-.285-18.385-.437-1.986-1.677-3.83-3.092-4.599-.435-.237-3.224-.538-6.198-.67-4.982-.221-5.54-.318-7.113-1.24-2.494-1.462-3.181-3.041-3.188-7.327-.013-8.189-3.421-15.792-10.155-22.654-4.797-4.889-10.149-8.198-16.257-10.052-1.462-.444-4.736-.595-15.702-.725-17.207-.203-21.026.15-26.884 2.483-10.8 4.302-18.56 13.368-21.39 24.99-.532 2.183-.635 5.682-.761 25.779-.157 25.177.016 28.874 1.59 33.864 1.299 4.122 2.611 6.648 5.313 10.234 5.146 6.83 12.86 11.763 20.572 13.156 3.67.663 48.948.829 53.585.197z" fill="#fff"/><path d="M67.575 75.717c-4.123-1.136-5.663-7.051-2.633-10.111 1.937-1.955 2.472-2.029 14.595-2.029 10.883 0 11.249.023 12.848.831 2.31 1.167 3.314 2.812 3.314 5.432 0 2.367-.943 4.025-3.046 5.357-1.129.716-1.804.76-12.467.823-6.584.039-11.83-.087-12.611-.303zM67.058 115.526c-1.769-.771-3.417-2.913-3.702-4.813-.272-1.809.638-4.296 2.032-5.558 1.757-1.59 2.528-1.643 24.134-1.66 22.227-.017 22.111-.027 24.219 1.941 2.976 2.78 2.349 7.728-1.239 9.76l-3.686.6-19.213.224c-16.883.198-21.666-.111-22.545-.494z" fill="#f57d00"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,4 @@
<svg width="100%" height="100%" viewBox="0 0 500 500" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path d="M405.365,303.996c0,20.375 -11.207,30.563 -31.582,31.582l-248.584,0c-20.376,0 -31.583,-10.188 -31.583,-31.582c0,-20.376 11.207,-30.564 31.583,-31.583l249.602,0c20.376,1.019 30.564,11.207 30.564,31.583Zm-30.564,46.864l-249.602,0c-20.376,0 -31.583,10.188 -31.583,31.582c0,20.376 11.207,30.564 31.583,31.583l249.602,0c20.376,0 31.583,-10.188 31.583,-31.583c-1.019,-21.394 -11.207,-31.582 -31.583,-31.582Zm77.428,-172.175c-20.376,0 -31.582,10.188 -31.582,30.563l0,172.175c0,20.376 11.206,30.564 31.582,31.583c30.564,-1.019 46.864,-31.583 46.864,-93.729l0,-77.427c0,-41.771 -16.3,-62.146 -46.864,-63.165Zm-404.458,0c-30.564,1.019 -46.864,21.394 -46.864,63.165l0,77.427c0,62.146 16.3,92.71 46.864,93.729c20.376,0 31.582,-10.188 31.582,-31.583l0,-171.156c-1.019,-20.375 -11.206,-30.563 -31.582,-31.582Zm404.458,-15.282c0,-51.958 -27.507,-76.409 -77.428,-77.428l-249.602,0c-50.94,1.019 -77.428,26.489 -77.428,77.428c30.563,0 46.864,16.301 46.864,46.864c0,30.564 16.301,46.864 46.864,46.864l218.021,0c30.563,0 46.864,-16.3 46.864,-46.864c-1.019,-31.582 16.3,-45.845 45.845,-46.864Z" style="fill:#e42528;fill-rule:nonzero;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg t="1657361174041" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4922" width="32" height="32">
<path d="M259.072 303.104q30.72 0 52.736 22.016t22.016 53.76q0 30.72-22.016 52.736t-52.736 22.016q-31.744 0-53.248-22.016t-21.504-52.736q0-31.744 21.504-53.76t53.248-22.016zM864.256 57.344q43.008 0 69.12 28.672t26.112 65.536l0 550.912q0 23.552-16.896 39.936t-40.448 16.384l-70.656 0 0-123.904 44.032 0q11.264 0 19.456-8.192t8.192-20.48q0-11.264-8.192-19.456t-19.456-8.192l-44.032 0 0-79.872 44.032 0q11.264 0 19.456-8.192t8.192-19.456-8.192-19.968-19.456-8.704l-44.032 0 0-72.704 44.032 0q11.264 0 19.456-8.192t8.192-20.48q0-11.264-8.192-19.456t-19.456-8.192l-44.032 0 0-86.016q0-57.344-26.624-80.896t-90.112-23.552l-394.24 0 0-9.216q0-23.552 16.896-39.936t40.448-16.384l486.4 0zM692.224 184.32q39.936 0 57.856 23.04t17.92 59.904l0 565.248q0 23.552-19.456 43.52t-48.128 19.968l-572.416 0q-24.576 0-44.032-20.48t-19.456-48.128l0-575.488q0-29.696 16.384-48.64t43.008-18.944l568.32 0zM703.488 291.84q0-17.408-10.752-30.208t-34.304-12.8l-488.448 0q-4.096 0-11.264 1.536t-14.336 5.12-12.288 9.728-5.12 15.36l0 274.432q8.192 9.216 23.04 22.016t34.816 23.552 44.544 18.432 53.248 7.68q43.008 0 75.264-13.824t59.904-34.816 54.272-45.056 58.88-45.568 73.728-36.352 98.816-16.896l0-142.336z" p-id="4923" fill="#1296db"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,9 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42.4 39.5">
<g fill="#007EE5">
<path d="M12.5 0L0 8.1l8.7 7 12.5-7.8"/>
<path d="M0 21.9l12.5 8.2 8.7-7.3-12.5-7.7m12.5 7.7l8.8 7.3L42.4 22l-8.6-6.9m8.6-7L30 0l-8.8 7.3 12.6 7.8"/>
<path d="M21.3 24.4l-8.8 7.3-3.7-2.5V32l12.5 7.5L33.8 32v-2.8L30 31.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

1
src/assets/iconGitea.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="32" height="32"><path d="M395.9 484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12z" fill="#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6zM125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1zm300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1z" fill="#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8-1.9 8 2 16.3 9.1 20 7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3 7.8 4 17.4 1.7 22.5-5.3 5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8l-24.6 50.4z" fill="#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

5
src/assets/iconGitee.svg Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1652950823759" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2991" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs><style type="text/css"></style></defs>
<path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="2992"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,6 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 58">
<g fill="none" fill-rule="evenodd">
<path d="m1324.62 140c-16.355 0-29.616 13.219-29.616 29.527 0 13.04 8.485 24.11 20.256 28.01 1.482.27 2.02-.642 2.02-1.425 0-.7-.025-2.557-.04-5.02-8.238 1.784-9.976-3.958-9.976-3.958-1.347-3.411-3.289-4.317-3.289-4.317-2.689-1.832.204-1.796.204-1.796 2.973.21 4.536 3.043 4.536 3.043 2.642 4.511 6.931 3.208 8.62 2.454.269-1.909 1.033-3.21 1.88-3.948-6.576-.745-13.491-3.279-13.491-14.592 0-3.223 1.155-5.858 3.049-7.922-.305-.747-1.322-3.748.289-7.814 0 0 2.487-.794 8.145 3.03 2.362-.656 4.896-.982 7.415-.995 2.515.013 5.05.339 7.415.995 5.655-3.821 8.136-3.03 8.136-3.03 1.616 4.065.6 7.07.295 7.814 1.898 2.064 3.045 4.7 3.045 7.922 0 11.343-6.925 13.838-13.524 14.569 1.064.912 2.01 2.713 2.01 5.468 0 3.946-.036 7.13-.036 8.098 0 .79.533 1.709 2.036 1.421 11.758-3.913 20.238-14.971 20.238-28.01 0-16.309-13.262-29.527-29.62-29.527" transform="translate(-1295-140)" fill="#181616"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1010 B

View File

@ -0,0 +1,6 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 58">
<g fill="none" fill-rule="evenodd">
<path d="m1324.62 140c-16.355 0-29.616 13.219-29.616 29.527 0 13.04 8.485 24.11 20.256 28.01 1.482.27 2.02-.642 2.02-1.425 0-.7-.025-2.557-.04-5.02-8.238 1.784-9.976-3.958-9.976-3.958-1.347-3.411-3.289-4.317-3.289-4.317-2.689-1.832.204-1.796.204-1.796 2.973.21 4.536 3.043 4.536 3.043 2.642 4.511 6.931 3.208 8.62 2.454.269-1.909 1.033-3.21 1.88-3.948-6.576-.745-13.491-3.279-13.491-14.592 0-3.223 1.155-5.858 3.049-7.922-.305-.747-1.322-3.748.289-7.814 0 0 2.487-.794 8.145 3.03 2.362-.656 4.896-.982 7.415-.995 2.515.013 5.05.339 7.415.995 5.655-3.821 8.136-3.03 8.136-3.03 1.616 4.065.6 7.07.295 7.814 1.898 2.064 3.045 4.7 3.045 7.922 0 11.343-6.925 13.838-13.524 14.569 1.064.912 2.01 2.713 2.01 5.468 0 3.946-.036 7.13-.036 8.098 0 .79.533 1.709 2.036 1.421 11.758-3.913 20.238-14.971 20.238-28.01 0-16.309-13.262-29.527-29.62-29.527" transform="translate(-1295-140)" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

12
src/assets/iconGitlab.svg Normal file
View File

@ -0,0 +1,12 @@
<svg width="100%" height="100%" viewBox="0 0 30 30" version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<path d="M14.581,28.019l5.369,-16.526l-10.738,0l5.369,16.526l0,0Z" style="fill:#e24329;"/>
<path d="M14.581,28.019l-5.37,-16.526l-7.525,0l12.895,16.526l0,0Z" style="fill:#fc6d26;"/>
<path d="M1.686,11.493l-1.632,5.022c-0.148,0.458 0.015,0.96 0.404,1.243l14.123,10.261l-12.895,-16.526l0,0Z" style="fill:#fca326;"/>
<path d="M1.686,11.493l7.526,0l-3.235,-9.953c-0.166,-0.512 -0.89,-0.512 -1.057,0l-3.234,9.953l0,0Z" style="fill:#e24329;"/>
<path d="M14.581,28.019l5.369,-16.526l7.526,0l-12.895,16.526l0,0Z" style="fill:#fc6d26;"/>
<path d="M27.476,11.493l1.631,5.022c0.149,0.458 -0.014,0.96 -0.404,1.243l-14.122,10.261l12.895,-16.526l0,0Z" style="fill:#fca326;"/>
<path d="M27.476,11.493l-7.526,0l3.234,-9.953c0.167,-0.512 0.891,-0.512 1.058,0l3.234,9.953l0,0Z" style="fill:#e24329;"/>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

13
src/assets/iconGoogle.svg Normal file
View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48">
<defs>
<path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/>
</defs>
<clipPath id="b">
<use xlink:href="#a" overflow="visible"/>
</clipPath>
<path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/>
<path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/>
<path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/>
<path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@ -0,0 +1,8 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 133156 115341">
<g>
<polygon style="fill:#3777E3" points="22194,115341 44385,76894 133156,76894 110963,115341 "/>
<polygon style="fill:#FFCF63" points="88772,76894 133156,76894 88772,0 44385,0 "/>
<polygon style="fill:#11A861" points="0,76894 22194,115341 66578,38447 44385,0 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 511">
<path d="M255.912,0.08c1.4,0.8 2.6,2 3.7,3.2c41.3,41.5 82.7,83 123.899,124.6c-26,25.6 -51.6,51.6 -77.399,77.3c-9.7,9.8 -19.601,19.4 -29.2,29.4c-7.2,-17.4 -14.1,-34.9 -21,-52.4c0,-18.2 0.1,-36.4 0,-54.7c-0.1,-42.4 -0.2,-84.9 0,-127.4l0,0Z" style="fill:#dc4b3e;fill-rule:nonzero;stroke:#dd4b39;stroke-width:0.09px;"/>
<path d="M127.812,127.48l128.1,0c0.1,18.3 0,36.5 0,54.7c-7.1,17.2 -14,34.5 -20.8,51.9c-2.2,-1.2 -3.8,-3 -5.5,-4.8l-101.4,-101.4l-0.4,-0.4Z" style="fill:#ff9e0e;fill-rule:nonzero;stroke:#ef851c;stroke-width:0.09px;"/>
<path d="M383.511,127.88l0.4,-0.3c-0.1,42.6 -0.1,85.3 0,127.9l-55.1,0c-17.2,-7.2 -34.601,-13.8 -51.9,-20.9c9.6,-10 19.5,-19.6 29.2,-29.4c25.801,-25.7 51.4,-51.7 77.4,-77.3l0,0Z" style="fill:#af195a;fill-rule:nonzero;stroke:#7e3794;stroke-width:0.09px;"/>
<path d="M106.912,148.98c7.2,-6.9 13.9,-14.3 21.3,-21.1l101.4,101.4c1.7,1.8 3.3,3.6 5.5,4.8c-2.3,1.7 -5.2,2.3 -7.8,3.5c-14.801,6 -29.801,11.6 -44.5,18c-18.301,-0.2 -36.601,-0.1 -54.9,-0.1c-42.6,-0.1 -85.2,0.2 -127.8,-0.1c35.5,-35.6 71.2,-71 106.8,-106.4l0,0Z" style="fill:#ffc112;fill-rule:nonzero;stroke:#ffbb1b;stroke-width:0.09px;"/>
<path d="M127.912,255.48c18.3,0 36.6,-0.1 54.9,0.1c17.3,7.1 34.6,13.8 51.899,20.8c-28.399,28.8 -57.099,57.2 -85.599,85.9c-7.2,6.8 -13.7,14.3 -21.3,20.7c0,-42.5 -0.1,-85 0.1,-127.5Z" style="fill:#17a05e;fill-rule:nonzero;stroke:#1a8763;stroke-width:0.09px;"/>
<path d="M328.812,255.48l55.1,0c42.5,0.1 85.1,-0.1 127.6,0.1c-27.3,27.7 -55,55.1 -82.399,82.6c-15.2,15.1 -30.2,30.399 -45.4,45.3c-34,-34.4 -68.5,-68.4 -102.6,-102.8c-1.4,-1.5 -2.9,-2.8 -4.601,-3.8c2.9,-1.801 6.101,-2.7 9.2,-4c14.4,-5.8 28.799,-11.4 43.1,-17.4l0,0Z" style="fill:#4587f4;fill-rule:nonzero;stroke:#427fed;stroke-width:0.09px;"/>
<path d="M234.712,276.38c7.3,17.399 13.9,35 21.2,52.399c-0.1,18.2 0,36.5 -0.1,54.7l0,88c-0.2,13.1 0.3,26.2 -0.2,39.2c-2.101,-1 -3.4,-2.9 -5.101,-4.5c-40.899,-41.099 -81.699,-82.199 -122.699,-123.199c7.6,-6.4 14.1,-13.9 21.3,-20.7c28.5,-28.7 57.2,-57.1 85.6,-85.9Z" style="fill:#8dc44d;fill-rule:nonzero;stroke:#65b045;stroke-width:0.09px;"/>
<path d="M276.511,276.88c1.7,1 3.2,2.3 4.601,3.8c34.1,34.4 68.6,68.4 102.6,102.8c-42.7,-0.1 -85.3,0.1 -127.899,0c0.1,-18.2 0,-36.5 0.1,-54.7c6.699,-17.3 13.899,-34.5 20.598,-51.9l0,0Z" style="fill:#3569d6;fill-rule:nonzero;stroke:#43459d;stroke-width:0.09px;"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

25
src/assets/iconSmms.svg Normal file
View File

@ -0,0 +1,25 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve"> <image id="image0" width="32" height="32" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAB7FBMVEUAAAB3AP+IAP+IAP+H
AP+IAP+IAP+GAP+HAP+AAP+GAP+IAP+IAP+HAP+KAP+OAP+IAP+IAP+IAP9xAP+IAP+IAP+KAP+F
AP+HAP+IAP+IAP+DAP+AAP+HAP+HAP+AAP+IAP+IAP+JAP+HAP+IAP+HAP+IAP+HAP+HAP+IAP+J
AP+GAP+IAP+IAP+IAP+HAP+HAP+HAP+HAP+IAP+IAP+IAP+HAP+IAP+HAP+IAP+IAP+HAP+HAP+H
AP+DAP+HAP+DAP+HAP+IAP+IAP+JAv+mQv+8cP+9c/+sTv+MCf+iOP/s1//////05/+mQf+hNv/6
9v/37/+QE//kx//Yrf+pR/+nRP/YrP+1Yf+SF//+/v+TGP/Df/+gNP/Egf+lPv+5af+aKP/8+P+j
O//asf/27f+RFf+IAv/x4//37v/NlP/x4v+jOv+4Z//Rn/+LB/+IAf/Xqv+/eP+LBv/Wqf+7b/+3
Zv/euP+vVf/Nlv/8+v+bKf/Dgf/Ghv/Ol/+eMf/9/P+vU/+QEf/48f/Sof+UG//ctP/48P/v3v+M
CP/z5f/16/+aJ/+SFv/Zr//ozv/euf+cKv+WHv+tUP/r1P+mQP/Egv/Lkf+PEP/v3P/Jjf/duf/+
/f/fu/+kPf+2Y//Cff/Bev+xWP+TGf+sor+CAAAAQ3RSTlMAD1qczOv7nFkOJp72nSUJjfqLCdTS
IzLqL+kjCtXRCI+JJ/v5oJoR9/QNXVafmNDI8Oj++PzszJsP9SSO0iWKJ5/LDpe2AgAAAAFiS0dE
TPdvEPMAAAAHdElNRQfmBhwAJyh2NlUnAAAByUlEQVQ4y2NggANGJmYWVjY2VhZ2Dk4GTMDFzeMM
Bzy8fGjS/AKCzihAUEgYWZ5PxBkDiIoh5MVZnbEACUm4fiR5F1c3dw9PCFtKGiIvI4uQ9/L2AQJf
PwhPTh6sQAEh7x/gAwaBQRC+IkheSRmhINjHJyQ0LNzHJwLCVwFZooqQj4zyiYp0do728YmBiqgx
MKhrIBTEAk2Pc3aO9/FJgIpoajFoI3ksEaggKdk5xSc1DSakw6CLpCA9A6giM8snOwcupMegjxw2
uWA/5OUjRAwYDJEVFBSCFEQVIUSMGIxRgre4BKyiFC5gwmCKkC0rr3AuqASpqKqGiZkymMHla3x8
amG21MEEzRjMYcz6Bh+fRiDd1AxU4A0TZWFghjFbgOKtIEYbkNEOE7VgYIIxO4DinTAFXTBRDgZL
WFLsBor3AOlekBV9UEErYPLlhrL7geITJk6aPAXkz6lQQWZQcoYm12k+CDAdKi9oDUoQNhDOjJkw
6ahZLlAFQpAkB03SrbOjQNJz5s6DudAWmvTtYIm2Z/6ChYsWw0NOSozYZI8949hKI2ctGQFlVGlB
e5SsBwQOjkiZ14rZDkv+tmRiNjdjU2Z1skDO/gDtseT0Fzic2AAAACV0RVh0ZGF0ZTpjcmVhdGUA
MjAyMi0wNi0yOFQwMDozOTo0MCswMDowMPmC6NgAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDYt
MjhUMDA6Mzk6NDArMDA6MDCI31BkAAAAAElFTkSuQmCC" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill-rule="evenodd" stroke-linejoin="round" clip-rule="evenodd" stroke-miterlimit="1.414"><path d="M 24 6 c 0 -3.3 -2.7 -6 -6 -6 H 6 C 2.7 0 0 2.7 0 6 v 12 c 0 3.3 2.7 6 6 6 h 12 c 3.3 0 6 -2.7 6 -6 V 6 z" fill="none"/><clipPath id="prefix__a"><path d="M 24 6 c 0 -3.3 -2.7 -6 -6 -6 H 6 C 2.7 0 0 2.7 0 6 v 12 c 0 3.3 2.7 6 6 6 h 12 c 3.3 0 6 -2.7 6 -6 V 6 z"/></clipPath><g clip-path="url(#prefix__a)"><path d="M 24 0 H 0 l 12 12 L 24 0 z" fill="gold"/><path d="M 0 0 v 24 l 12 -12 L 0 0 z" fill="#a5c700"/><path d="M 0 24 h 24 L 12 12 L 0 24 z" fill="#ff8a00"/><path d="M 24 24 V 0 L 12 12 l 12 12 z" fill="#66aefd"/><path d="M 22.5 -1.5 L 12 9 l 3 3 L 25.5 1.5 l -3 -3 z" fill="url(#prefix___Linear2)"/><path d="M 25.5 22.5 L 15 12 l -3 3 l 10.5 10.5 l 3 -3 z" fill="url(#prefix___Linear3)"/><path d="M 1.5 25.5 L 12 15 l -3 -3 l -10.5 10.5 l 3 3 z" fill="url(#prefix___Linear4)"/><path d="M -1.5 1.5 L 9 12 l 3 -3 L 1.5 -1.5 l -3 3 z" fill="url(#prefix___Linear5)"/></g><path d="M 21.8 5.9 c 0 -2.2 -1.8 -4 -4 -4 H 6.3 c -2.2 0 -4 1.8 -4 4 v 11.5 c 0 2.2 1.8 4 4 4 h 11.5 c 2.2 0 4 -1.8 4 -4 V 5.9 z" fill="#ffffff"/><path d="M 4.6 6 H 6 V 4.2 h 1.4 V 6 h 1.7 V 4.2 h 1.4 V 6 h 1.4 v 1.7 h -1.4 v 1.9 h 1.4 v 1.7 h -1.4 v 1.8 H 9.1 v -1.8 H 7.4 v 1.8 H 6 v -1.8 H 4.6 V 9.6 H 6 V 7.7 H 4.6 V 6 z m 2.8 1.7 v 1.9 h 1.7 V 7.7 H 7.4 z M 10 14 v 6 h 4 v -2 h -2 v -2 h 2 v -2 H 10 z m 5 0 v 6 h 2 v -3 l 1 3 h 2 v -6 h -2 v 3 l -1 -3 h -2 z M 7 18 l 0 2 l 2 0 l 0 -2 l -2 0 Z" fill="#737373"/><defs><linearGradient id="prefix___Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-2.99995 -3 3 -2.99995 23.997 3.003)"><stop offset="0" stop-color="#66aefd"/><stop offset="1" stop-color="gold"/></linearGradient><linearGradient id="prefix___Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(3 -2.99995 2.99995 3 20.999 24.003)"><stop offset="0" stop-color="#ff8a00"/><stop offset="1" stop-color="#66aefd"/></linearGradient><linearGradient id="prefix___Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.99995 3 -3 2.99995 -.003 21.001)"><stop offset="0" stop-color="#a5c700"/><stop offset="1" stop-color="#ff8a00"/></linearGradient><linearGradient id="prefix___Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3 2.99995 -2.99995 -3 2.997 .003)"><stop offset="0" stop-color="gold"/><stop offset="1" stop-color="#a5c700"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<g id="_x32__stroke">
<g id="Wordpress_1_">
<rect clip-rule="evenodd" fill="none" fill-rule="evenodd" height="128" width="128"/>
<path clip-rule="evenodd" d="M65.123,69.595l-19.205,55.797 c5.736,1.688,11.8,2.608,18.081,2.608c7.452,0,14.6-1.288,21.253-3.628c-0.168-0.276-0.328-0.564-0.456-0.88L65.123,69.595z M120.16,33.294c0.276,2.04,0.432,4.224,0.432,6.58c0,6.492-1.216,13.792-4.868,22.924l-19.549,56.517 C115.204,108.223,128,87.606,128,63.998C128,52.87,125.156,42.41,120.16,33.294z M107.204,60.769 c0-7.912-2.844-13.388-5.276-17.648c-3.244-5.276-6.288-9.74-6.288-15.012c0-5.884,4.46-11.36,10.748-11.36 c0.284,0,0.552,0.036,0.828,0.052C95.832,6.368,80.659,0,63.999,0C41.638,0,21.969,11.472,10.525,28.844 c1.504,0.048,2.92,0.076,4.12,0.076c6.692,0,17.057-0.812,17.057-0.812c3.448-0.204,3.856,4.868,0.408,5.272 c0,0-3.468,0.408-7.324,0.612l23.305,69.321l14.008-42.005L52.13,33.992c-3.448-0.204-6.716-0.612-6.716-0.612 c-3.448-0.204-3.044-5.476,0.408-5.272c0,0,10.568,0.812,16.857,0.812c6.692,0,17.057-0.812,17.057-0.812 c3.452-0.204,3.856,4.868,0.408,5.272c0,0-3.472,0.408-7.324,0.612l23.129,68.793l6.388-21.328 C105.096,72.601,107.204,66.245,107.204,60.769z M0,63.997c0,25.332,14.72,47.225,36.069,57.597L5.54,37.952 C1.992,45.909,0,54.717,0,63.997z" fill="#00759D" fill-rule="evenodd" id="Wordpress"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 152 116">
<g>
<path d="M70.125,30.375l0,84.675l-70.125,0l70.125,-84.675Z" style="fill:#03363d;fill-rule:nonzero;"/>
<path d="M70.125,0c0,19.35 -15.675,35.025 -35.025,35.025c-19.35,0 -35.1,-15.675 -35.1,-35.025l70.125,0Z" style="fill:#03363d;fill-rule:nonzero;"/>
<path d="M81.675,115.05c0,-19.35 15.675,-35.025 35.025,-35.025c19.35,0 35.025,15.675 35.025,35.025l-70.05,0Z" style="fill:#03363d;fill-rule:nonzero;"/>
<path d="M81.675,84.675l0,-84.675l70.125,0l-70.125,84.675Z" style="fill:#03363d;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 605 B

1
src/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

109
src/components/App.vue Normal file
View File

@ -0,0 +1,109 @@
<template>
<div class="app" :class="classes" @keydown.esc="close">
<splash-screen v-if="!ready"></splash-screen>
<layout v-else></layout>
<modal></modal>
<notification></notification>
<context-menu></context-menu>
</div>
</template>
<script>
import '../styles';
import '../styles/markdownHighlighting.scss';
import '../styles/app.scss';
import Layout from './Layout';
import Modal from './Modal';
import Notification from './Notification';
import ContextMenu from './ContextMenu';
import SplashScreen from './SplashScreen';
import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc';
import tempFileSvc from '../services/tempFileSvc';
import store from '../store';
import './common/vueGlobals';
import utils from '../services/utils';
import providerRegistry from '../services/providers/common/providerRegistry';
const themeClasses = {
light: ['app--light'],
dark: ['app--dark'],
};
export default {
components: {
Layout,
Modal,
Notification,
ContextMenu,
SplashScreen,
},
data: () => ({
ready: false,
}),
computed: {
classes() {
const result = themeClasses[store.getters['data/computedSettings'].colorTheme];
return Array.isArray(result) ? result : themeClasses.light;
},
},
methods: {
close() {
tempFileSvc.close();
},
//
viewFileByPath(path) {
// md
if (!path) {
return;
}
const currDirNode = store.getters['explorer/selectedNodeFolder'];
if (path.slice(-3) === '.md') {
const rootNode = store.getters['explorer/rootNode'];
const node = utils.findNodeByPath(rootNode, currDirNode, path);
if (!node) {
return;
}
store.commit('explorer/setSelectedId', node.item.id);
// Prevent from freezing the UI while loading the file
setTimeout(() => {
store.commit('file/setCurrentId', node.item.id);
}, 10);
} else {
const workspace = store.getters['workspace/currentWorkspace'];
const provider = providerRegistry.providersById[workspace.providerId];
if (provider == null) {
return;
}
const absolutePath = utils.getAbsoluteFilePath(currDirNode, path);
const url = provider.getFilePathUrl(absolutePath);
if (url) {
window.open(url, '_blank');
}
}
},
},
async created() {
window.viewFileByPath = this.viewFileByPath;
try {
await syncSvc.init();
await networkSvc.init();
// store
const editTheme = localStorage.getItem('theme/currEditTheme');
store.dispatch('theme/setEditTheme', editTheme || 'default');
// store
const previewTheme = localStorage.getItem('theme/currPreviewTheme');
store.dispatch('theme/setPreviewTheme', previewTheme || 'default');
this.ready = true;
tempFileSvc.setReady();
} catch (err) {
if (err && err.message === 'RELOAD') {
window.location.reload();
} else if (err && err.message !== 'RELOAD') {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}
}
},
};
</script>

View File

@ -0,0 +1,109 @@
<template>
<div class="button-bar">
<div class="button-bar__inner button-bar__inner--top">
<button class="button-bar__button button-bar__button--navigation-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'切换导航栏'">
<icon-navigation-bar></icon-navigation-bar>
</button>
<button class="button-bar__button button-bar__button--side-preview-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'切换侧边预览'">
<icon-side-preview></icon-side-preview>
</button>
<button class="button-bar__button button-bar__button--editor-toggler button" @click="toggleEditor(false)" v-title="'阅读模式'">
<icon-eye></icon-eye>
</button>
</div>
<div class="button-bar__inner button-bar__inner--bottom">
<button class="button-bar__button button-bar__button--focus-mode-toggler button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'切换对焦模式'">
<icon-target></icon-target>
</button>
<button class="button-bar__button button-bar__button--scroll-sync-toggler button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'切换滚动同步'">
<icon-scroll-sync></icon-scroll-sync>
</button>
<button class="button-bar__button button-bar__button--status-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'切换状态栏'">
<icon-status-bar></icon-status-bar>
</button>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
export default {
computed: {
...mapState([
'light',
]),
...mapGetters('data', [
'layoutSettings',
]),
},
methods: mapActions('data', [
'toggleNavigationBar',
'toggleEditor',
'toggleSidePreview',
'toggleStatusBar',
'toggleFocusMode',
'toggleScrollSync',
]),
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.button-bar {
position: absolute;
width: 100%;
height: 100%;
}
.button-bar__inner {
position: absolute;
}
.button-bar__inner--bottom {
bottom: 0;
}
.button-bar__button {
color: rgba(0, 0, 0, 0.2);
display: block;
width: 26px;
height: 26px;
padding: 2px;
margin: 3px 0;
.app--dark & {
color: rgba(255, 255, 255, 0.15);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.2);
.app--dark & {
color: rgba(255, 255, 255, 0.15);
background-color: $navbar-hover-background;
}
}
}
.button-bar__button--on {
color: rgba(0, 0, 0, 0.4);
.app--dark & {
color: rgba(255, 255, 255, 0.4);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.4);
.app--dark & {
color: rgba(255, 255, 255, 0.4);
}
}
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<pre class="code-editor textfield prism" :disabled="disabled"></pre>
</template>
<script>
import Prism from 'prismjs';
import cledit from '../services/editor/cledit';
export default {
props: ['value', 'lang', 'disabled', 'scrollClass'],
mounted() {
const preElt = this.$el;
let scrollElt = preElt;
const scrollCls = this.scrollClass || 'modal';
while (scrollElt && !scrollElt.classList.contains(scrollCls)) {
scrollElt = scrollElt.parentNode;
}
if (scrollElt) {
const clEditor = cledit(preElt, scrollElt);
clEditor.on('contentChanged', value => this.$emit('changed', value));
clEditor.init({
content: this.value,
sectionHighlighter: section => Prism.highlight(section.text, Prism.languages[this.lang]),
});
clEditor.toggleEditable(!this.disabled);
}
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.code-editor {
margin: 0;
font-family: $font-family-monospace;
font-size: $font-size-monospace;
font-variant-ligatures: no-common-ligatures;
word-break: break-word;
word-wrap: normal;
height: auto;
caret-color: #000;
min-height: 160px;
overflow: auto;
padding: 0.2em 0.4em;
.app--dark & {
caret-color: $editor-color-dark-low;
}
* {
line-height: $line-height-base;
font-size: inherit !important;
}
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="context-menu" v-if="items.length" @click="close()" @contextmenu.prevent="close()">
<div class="context-menu__inner flex flex--column" :style="{ left: coordinates.left + 'px', top: coordinates.top + 'px' }" @click.stop>
<div v-for="(item, idx) in items" :key="idx">
<div class="context-menu__separator" v-if="item.type === 'separator'"></div>
<div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div>
<a class="context-menu__item" href="javascript:void(0)" v-else @click="close(item)">{{item.name}}</a>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import store from '../store';
export default {
computed: {
...mapState('contextMenu', [
'coordinates',
'items',
'resolve',
]),
},
methods: {
close(item = null) {
this.resolve(item);
store.dispatch('contextMenu/close');
},
},
};
</script>
<style lang="scss">
.context-menu {
position: absolute;
width: 100%;
height: 100%;
font-size: 14px;
line-height: 18px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
user-select: none;
}
$padding: 5px;
.context-menu__inner {
position: absolute;
background-color: #ebebeb;
border-radius: $padding;
padding: $padding 0;
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.16), 0 3px 10px 1px rgba(0, 0, 0, 0.12);
}
.context-menu__item {
display: block;
color: #333;
text-decoration: none;
padding: 0 25px;
}
a.context-menu__item {
&:active,
&:focus,
&:hover {
background-color: #338dfc;
color: #fff;
}
}
.context-menu__item--disabled {
color: #aaa;
}
.context-menu__separator {
border-top: 2px solid #dcdcdd;
margin: $padding 0;
}
</style>

171
src/components/Editor.vue Normal file
View File

@ -0,0 +1,171 @@
<template>
<div class="editor" ondrop="return false;">
<pre class="editor__inner markdown-highlighting" :style="{padding: styles.editorPadding}" :class="{monospaced: computedSettings.editor.monospacedFontOnly}"></pre>
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<comment-list v-if="styles.editorGutterWidth"></comment-list>
<editor-new-discussion-button v-if="!isCurrentTemp"></editor-new-discussion-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import CommentList from './gutters/CommentList';
import EditorNewDiscussionButton from './gutters/EditorNewDiscussionButton';
import store from '../store';
import editorSvc from '../services/editorSvc';
import imageSvc from '../services/imageSvc';
import utils from '../services/utils';
export default {
components: {
CommentList,
EditorNewDiscussionButton,
},
computed: {
...mapGetters('file', [
'isCurrentTemp',
]),
...mapGetters('layout', [
'styles',
]),
...mapGetters('data', [
'computedSettings',
]),
},
methods: {
async processUpload(items) {
let file = null;
if (!items || items.length === 0) {
return;
}
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.indexOf('image') !== -1) {
file = items[i].getAsFile();
break;
}
}
if (!file) {
return;
}
const imgId = utils.uid();
store.dispatch('img/setCurrImgId', imgId);
editorSvc.pagedownEditor.uiManager.doClick('imageUploading');
try {
const { url, error } = await imageSvc.updateImg(file);
//
if (error) {
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, `[图片上传失败...(image-${imgId})]`);
store.dispatch('notification/error', error);
return;
}
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, `![输入图片说明](${url})`);
} catch (err) {
console.error(err); // eslint-disable-line no-console
editorSvc.clEditor.replaceAll(`[图片上传中...(image-${imgId})]`, `[图片上传失败...(image-${imgId})]`);
store.dispatch('notification/error', err);
}
},
},
mounted() {
//
const currImgStorageStr = localStorage.getItem('img/checkedStorage');
if (currImgStorageStr) {
store.commit('img/changeCheckedStorage', JSON.parse(currImgStorageStr));
}
//
const workspaceImgPath = localStorage.getItem('img/workspaceImgPath');
if (workspaceImgPath) {
store.commit('img/setWorkspaceImgPath', JSON.parse(workspaceImgPath));
}
const editorElt = this.$el.querySelector('.editor__inner');
const onDiscussionEvt = cb => (evt) => {
let elt = evt.target;
while (elt && elt !== editorElt) {
if (elt.discussionId) {
cb(elt.discussionId);
return;
}
elt = elt.parentNode;
}
};
const classToggler = toggle => (discussionId) => {
editorElt.getElementsByClassName(`discussion-editor-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.toggle('discussion-editor-highlighting--hover', toggle));
document.getElementsByClassName(`comment--discussion-${discussionId}`)
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
};
editorElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
editorElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
editorElt.addEventListener('click', onDiscussionEvt((discussionId) => {
store.commit('discussion/setCurrentDiscussionId', discussionId);
}));
editorElt.addEventListener('drop', (event) => {
const transItems = event.dataTransfer.items;
this.processUpload(transItems);
});
editorElt.addEventListener('paste', (event) => {
const pasteItems = (event.clipboardData || window.clipboardData).items;
this.processUpload(pasteItems);
});
this.$watch(
() => store.state.discussion.currentDiscussionId,
(discussionId, oldDiscussionId) => {
if (oldDiscussionId) {
editorElt.querySelectorAll(`.discussion-editor-highlighting--${oldDiscussionId}`)
.cl_each(elt => elt.classList.remove('discussion-editor-highlighting--selected'));
}
if (discussionId) {
editorElt.querySelectorAll(`.discussion-editor-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-editor-highlighting--selected'));
}
},
);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.editor {
position: absolute;
width: 100%;
height: 100%;
overflow: auto;
}
.editor__inner {
margin: 0;
font-family: $font-family-main;
font-variant-ligatures: no-common-ligatures;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
* {
line-height: $line-height-base;
}
.cledit-section {
font-family: inherit;
}
.hide {
display: none;
}
&.monospaced {
font-family: $font-family-monospace !important;
font-size: $font-size-monospace !important;
* {
font-size: inherit !important;
}
}
}
</style>

View File

@ -0,0 +1,197 @@
<template>
<div class="editor-in-page-buttons">
<ul>
<li :title="`查找 ${mod}+F`">
<a @click="showFind"><icon-search></icon-search></a>
</li>
<li :title="`替换 ${mod}+Alt+F`">
<a @click="showFindReplace"><icon-find-replace></icon-find-replace></a>
</li>
<li title="切换编辑主题">
<dropdown-menu :selected="selectedTheme" :options="allThemes" :closeOnItemClick="false" @change="changeTheme">
<icon-select-theme></icon-select-theme>
</dropdown-menu>
</li>
<li class="after">
<icon-ellipsis></icon-ellipsis>
</li>
</ul>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import store from '../store';
import editorSvc from '../services/editorSvc';
import DropdownMenu from './common/DropdownMenu';
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
export default {
components: {
DropdownMenu,
},
data: () => ({
mod,
allThemes: [{
name: '默认主题',
value: 'default',
}, {
name: '天蓝黑',
value: 'azure',
}, {
name: '冰山黑',
value: 'iceberg_contrast',
}, {
name: '黎明白',
value: 'dawn',
}, {
name: '孔雀黑',
value: 'peacock',
}, {
name: '薄荷黑',
value: 'mintchoc',
}, {
name: '薄荷绿',
value: 'spearmint',
}, {
name: '暗蓝黑',
value: 'slate',
}, {
name: '文墨黑',
value: 'carbonight',
}, {
name: '日光白',
value: 'solarized_light',
}, {
name: '咖啡黑',
value: 'espresso_libre',
}, {
name: '薰衣草黑',
value: 'lavender',
}, {
name: '耀斑黑',
value: 'solarflare',
}, {
name: 'Clouds白',
value: 'clouds',
}, {
name: 'Clouds黑',
value: 'clouds_midnight',
}, {
name: 'GitHub白',
value: 'github',
}, {
name: '自定义',
value: 'custom',
}],
}),
computed: {
...mapGetters('theme', [
'currEditTheme',
'customEditThemeStyle',
]),
selectedTheme() {
return {
value: this.currEditTheme || 'default',
};
},
},
methods: {
...mapActions('data', [
'toggleSideBar',
]),
showFind() {
store.dispatch('findReplace/open', {
type: 'find',
findText: editorSvc.clEditor.selectionMgr.hasFocus() &&
editorSvc.clEditor.selectionMgr.getSelectedText(),
});
},
showFindReplace() {
store.dispatch('findReplace/open', {
type: 'replace',
findText: editorSvc.clEditor.selectionMgr.hasFocus() &&
editorSvc.clEditor.selectionMgr.getSelectedText(),
});
},
async changeTheme(item) {
await store.dispatch('theme/setEditTheme', item.value);
//
if (item.value === 'custom' && !this.customEditThemeStyle) {
this.toggleSideBar(true);
store.dispatch('data/setSideBarPanel', 'editTheme');
}
},
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.editor-in-page-buttons {
position: absolute;
top: 0;
left: -108px;
height: 34px;
padding: 5px;
background-color: rgba(84, 96, 114, 0.4);
border-radius: $border-radius-base;
transition: 0.5s;
display: flex;
.dropdown-menu {
display: none;
.dropdown-menu-items {
right: unset;
left: 0;
}
}
&:active,
&:focus,
&:hover {
left: 0;
transition: 0.5s;
background-color: #546072;
.dropdown-menu {
display: block;
}
}
ul {
padding: 0;
margin-left: 10px;
line-height: 20px;
li {
width: 16px;
display: inline-block;
vertical-align: middle;
list-style: none;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}
}
.icon {
color: #dea731;
opacity: 0.7;
&:active,
&:focus,
&:hover {
opacity: 1;
}
}
.after {
margin-left: 0;
margin-right: -6px;
}
}
</style>

225
src/components/Explorer.vue Normal file
View File

@ -0,0 +1,225 @@
<template>
<div class="explorer flex flex--column">
<div class="side-title flex flex--row flex--space-between">
<div class="flex flex--row" v-if="!showSearch">
<button class="side-title__button side-title__button--new-file button" @click="newItem()" v-title="'创建文件'">
<icon-file-plus></icon-file-plus>
</button>
<button class="side-title__button side-title__button--new-folder button" @click="newItem(true)" v-title="'创建文件夹'">
<icon-folder-plus></icon-folder-plus>
</button>
<button class="side-title__button side-title__button--delete button" @click="deleteItem()" v-title="'删除'">
<icon-delete></icon-delete>
</button>
<button class="side-title__button side-title__button--rename button" @click="editItem()" v-title="'重命名'">
<icon-pen></icon-pen>
</button>
<button class="side-title__button side-title__button--search button" @click="toSearch()" v-title="'搜索文件'">
<icon-file-search></icon-file-search>
</button>
</div>
<div class="flex flex--row" v-else>
<button class="side-title__button button" @click="back()" v-title="'返回资源管理器'">
<icon-dots-horizontal></icon-dots-horizontal>
</button>
<div class="side-title__title">
搜索文件
</div>
</div>
<button class="side-title__button side-title__button--close button" @click="toggleExplorer(false)" v-title="'关闭资源管理器'">
<icon-close></icon-close>
</button>
</div>
<div class="explorer__tree" :class="{'explorer__tree--new-item': !newChildNode.isNil}" v-if="!light" v-show="!showSearch" tabindex="0" @keydown.delete="deleteItem()">
<explorer-node :node="rootNode" :depth="0"></explorer-node>
</div>
<div class="explorer__search" tabindex="0" v-if="!light && showSearch">
<input type="text" v-model="searchText" class="text-input" placeholder="请输入关键字回车" @keyup.enter="search" />
<div class="explorer__search-list">
<div class="search-tips" v-if="searching">正在查询中...</div>
<a class="menu-entry button flex flex--row flex--align-center" :class="{'search-node--selected': currentFileId === fileItem.id}"
v-for="fileItem in searchItems" :key="fileItem.id" @click="clickSearch(fileItem)" href="javascript:void(0)">
{{ fileItem.name }}
</a>
<div class="search-tips">最多返回匹配的50个文档</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import ExplorerNode from './ExplorerNode';
import explorerSvc from '../services/explorerSvc';
import store from '../store';
import MenuEntry from './menus/common/MenuEntry';
import localDbSvc from '../services/localDbSvc';
import badgeSvc from '../services/badgeSvc';
export default {
components: {
ExplorerNode,
MenuEntry,
},
data: () => ({
currentFileId: '',
showSearch: false,
searching: false,
searchText: '',
searchItems: [],
}),
computed: {
...mapState([
'light',
]),
...mapState('explorer', [
'newChildNode',
]),
...mapGetters('explorer', [
'rootNode',
'selectedNode',
]),
workspaceId: () => store.getters['workspace/currentWorkspace'].id,
},
methods: {
...mapActions('data', [
'toggleExplorer',
]),
newItem: isFolder => explorerSvc.newItem(isFolder),
deleteItem: () => explorerSvc.deleteItem(),
editItem() {
const node = this.selectedNode;
if (!node.isTrash && !node.isTemp) {
store.commit('explorer/setEditingId', node.item.id);
}
},
back() {
this.showSearch = false;
},
toSearch() {
this.showSearch = true;
},
search() {
this.searchItems = [];
if (!this.searchText) {
return;
}
this.searching = true;
const allFileById = {};
const filterIds = [];
localDbSvc.getWorkspaceItems(this.workspaceId, (item) => {
if (item.type !== 'file' && item.type !== 'content') {
return;
}
if (item.type === 'file') {
allFileById[item.id] = item;
}
if (filterIds.length >= 50) {
return;
}
const fileId = item.id.split('/')[0];
//
if (filterIds.indexOf(fileId) > -1) {
return;
}
if (item.name && item.name.indexOf(this.searchText) > -1) {
filterIds.push(fileId);
}
if (item.text && item.text.indexOf(this.searchText) > -1) {
filterIds.push(fileId);
}
}, () => {
filterIds.forEach((it) => {
const file = allFileById[it];
if (file) {
this.searchItems.push(file);
}
});
this.searching = false;
badgeSvc.addBadge('searchFile');
});
},
clickSearch(item) {
const node = store.getters['explorer/nodeMap'][item.id];
if (!node) {
return;
}
store.commit('explorer/setSelectedId', item.id);
// Prevent from freezing the UI while loading the file
setTimeout(() => {
store.commit('file/setCurrentId', item.id);
}, 10);
},
},
created() {
this.$watch(
() => store.getters['file/current'].id,
(currentFileId) => {
this.currentFileId = currentFileId;
store.commit('explorer/setSelectedId', currentFileId);
store.dispatch('explorer/openNode', currentFileId);
}, {
immediate: true,
},
);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.explorer,
.explorer__tree {
height: 100%;
}
.explorer__tree {
overflow: auto;
/* fake element */
& > .explorer-node > .explorer-node__children > .explorer-node:last-child > .explorer-node__item {
height: 20px;
cursor: auto;
}
}
.explorer__search {
overflow: auto;
.explorer__search-list {
margin-top: 10px;
}
.menu-entry {
font-size: 14px;
padding: 5px;
}
.menu-entry__icon {
width: 0;
margin-left: 0;
border-bottom: 1px solid $hr-color;
}
.search-tips {
font-size: 10px;
background-color: rgba(255, 173, 51, 0.14902);
padding: 5px;
text-align: center;
}
.search-node--selected {
background-color: rgba(0, 0, 0, 0.2);
.app--dark & {
background-color: rgba(0, 0, 0, 0.4);
}
&:focus {
background-color: #39f;
color: #fff;
}
}
}
</style>

View File

@ -0,0 +1,292 @@
<template>
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--folder': node.isFolder, 'explorer-node--open': isOpen, 'explorer-node--trash': node.isTrash, 'explorer-node--temp': node.isTemp, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="node.noDrop || setDragTarget(node)" @dragleave.stop="isDragTarget && setDragTarget()" @drop.prevent.stop="onDrop" @contextmenu="onContextMenu">
<div class="explorer-node__item-editor" v-if="isEditing" :style="{paddingLeft: leftPadding}" draggable="true" @dragstart.stop.prevent>
<input type="text" class="text-input" v-focus @blur="submitEdit()" @keydown.stop @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingNodeName">
</div>
<div class="explorer-node__item" v-else :style="{paddingLeft: leftPadding}" @click="select()" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTarget()">
{{node.item.name}}
<icon-provider class="explorer-node__location" v-for="location in node.locations" :key="location.id" :provider-id="location.providerId"></icon-provider>
</div>
<div class="explorer-node__children" v-if="node.isFolder && isOpen">
<explorer-node v-for="node in node.folders" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node>
<div v-if="newChild" class="explorer-node__new-child" :class="{'explorer-node__new-child--folder': newChild.isFolder}" :style="{paddingLeft: childLeftPadding}">
<input type="text" class="text-input" v-focus @blur="submitNewChild()" @keydown.stop @keydown.enter="submitNewChild()" @keydown.esc.stop="submitNewChild(true)" v-model.trim="newChildName">
</div>
<explorer-node v-for="node in node.files" :key="node.item.id" :node="node" :depth="depth + 1"></explorer-node>
</div>
<button ref="copyId" v-clipboard="copyPath()" @click="info('路径已复制到剪切板!')" style="display: none;"></button>
</div>
</template>
<script>
import { mapMutations, mapActions } from 'vuex';
import workspaceSvc from '../services/workspaceSvc';
import explorerSvc from '../services/explorerSvc';
import store from '../store';
import badgeSvc from '../services/badgeSvc';
import utils from '../services/utils';
export default {
name: 'explorer-node', // Required for recursivity
props: ['node', 'depth'],
data: () => ({
editingValue: '',
}),
computed: {
leftPadding() {
return `${this.depth * 15}px`;
},
childLeftPadding() {
return `${(this.depth + 1) * 15}px`;
},
isSelected() {
return store.getters['explorer/selectedNode'] === this.node;
},
isEditing() {
return store.getters['explorer/editingNode'] === this.node;
},
isDragTarget() {
return store.getters['explorer/dragTargetNode'] === this.node;
},
isDragTargetFolder() {
return store.getters['explorer/dragTargetNodeFolder'] === this.node;
},
isOpen() {
return store.state.explorer.openNodes[this.node.item.id] || this.node.isRoot;
},
newChild() {
return store.getters['explorer/newChildNodeParent'] === this.node
&& store.state.explorer.newChildNode;
},
newChildName: {
get() {
return store.state.explorer.newChildNode.item.name;
},
set(value) {
store.commit('explorer/setNewItemName', value);
},
},
editingNodeName: {
get() {
return store.getters['explorer/editingNode'].item.name;
},
set(value) {
this.editingValue = value.trim();
},
},
},
methods: {
...mapMutations('explorer', [
'setEditingId',
]),
...mapActions('explorer', [
'setDragTarget',
]),
...mapActions('notification', [
'info',
]),
select(id = this.node.item.id, doOpen = true) {
const node = store.getters['explorer/nodeMap'][id];
if (!node) {
return false;
}
store.commit('explorer/setSelectedId', id);
if (doOpen) {
// Prevent from freezing the UI while loading the file
setTimeout(() => {
if (node.isFolder) {
store.commit('explorer/toggleOpenNode', id);
} else if (store.state.file.currentId !== id) {
store.commit('file/setCurrentId', id);
badgeSvc.addBadge('switchFile');
}
}, 10);
}
return true;
},
async submitNewChild(cancel) {
const { newChildNode } = store.state.explorer;
if (!cancel && !newChildNode.isNil && newChildNode.item.name) {
try {
if (newChildNode.isFolder) {
const item = await workspaceSvc.storeItem(newChildNode.item);
this.select(item.id);
badgeSvc.addBadge('createFolder');
} else {
const item = await workspaceSvc.createFile(newChildNode.item);
this.select(item.id);
badgeSvc.addBadge('createFile');
}
} catch (e) {
// Cancel
}
}
store.commit('explorer/setNewItem', null);
},
async submitEdit(cancel) {
const { item, isFolder } = store.getters['explorer/editingNode'];
const value = this.editingValue;
this.setEditingId(null);
if (!cancel && item.id && value && item.name !== value) {
try {
await workspaceSvc.storeItem({
...item,
name: value,
});
badgeSvc.addBadge(isFolder ? 'renameFolder' : 'renameFile');
} catch (e) {
// Cancel
}
}
},
setDragSourceId(evt) {
if (this.node.noDrag) {
evt.preventDefault();
return;
}
store.commit('explorer/setDragSourceId', this.node.item.id);
// Fix for Firefox
// See https://stackoverflow.com/a/3977637/1333165
evt.dataTransfer.setData('Text', '');
},
copyPath() {
let path = utils.getAbsoluteDir(this.node).replaceAll(' ', '%20');
path = path.indexOf('/') === 0 ? path : `/${path}`;
return this.node.isFolder ? path : `${path}.md`;
},
onDrop() {
const sourceNode = store.getters['explorer/dragSourceNode'];
const targetNode = store.getters['explorer/dragTargetNodeFolder'];
this.setDragTarget();
if (!sourceNode.isNil
&& !targetNode.isNil
&& sourceNode.item.id !== targetNode.item.id
) {
workspaceSvc.storeItem({
...sourceNode.item,
parentId: targetNode.item.id,
});
badgeSvc.addBadge(sourceNode.isFolder ? 'moveFolder' : 'moveFile');
}
},
async onContextMenu(evt) {
if (this.select(undefined, false)) {
evt.preventDefault();
evt.stopPropagation();
const item = await store.dispatch('contextMenu/open', {
coordinates: {
left: evt.clientX,
top: evt.clientY,
},
items: [{
name: '新建文件',
disabled: !this.node.isFolder || this.node.isTrash,
perform: () => explorerSvc.newItem(false),
}, {
name: '新建文件夹',
disabled: !this.node.isFolder || this.node.isTrash || this.node.isTemp,
perform: () => explorerSvc.newItem(true),
}, {
type: 'separator',
}, {
name: '重命名',
disabled: this.node.isTrash || this.node.isTemp,
perform: () => this.setEditingId(this.node.item.id),
}, {
name: '删除',
perform: () => explorerSvc.deleteItem(),
}, {
name: '复制路径',
disabled: this.node.isTrash || this.node.isTemp,
perform: () => this.$refs.copyId.click(),
}],
});
if (item) {
item.perform();
}
}
},
},
};
</script>
<style lang="scss">
$item-font-size: 14px;
.explorer-node--drag-target {
background-color: rgba(0, 128, 255, 0.2);
}
.explorer-node__item {
position: relative;
cursor: pointer;
font-size: $item-font-size;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 5px;
.explorer-node--selected > & {
background-color: rgba(0, 0, 0, 0.2);
.app--dark & {
background-color: rgba(0, 0, 0, 0.4);
}
.explorer__tree:focus & {
background-color: #39f;
color: #fff;
}
}
.explorer__tree--new-item & {
opacity: 0.33;
}
.explorer-node__location {
float: right;
width: 18px;
height: 18px;
margin: 2px 1px;
}
}
.explorer-node--trash,
.explorer-node--temp {
color: rgba(0, 0, 0, 0.5);
.app--dark & {
color: rgba(255, 255, 255, 0.5);
}
}
.explorer-node--folder > .explorer-node__item,
.explorer-node--folder > .explorer-node__item-editor,
.explorer-node__new-child--folder {
&::before {
content: '▹';
position: absolute;
margin-left: -13px;
}
}
.explorer-node--folder.explorer-node--open > .explorer-node__item,
.explorer-node--folder.explorer-node--open > .explorer-node__item-editor {
&::before {
content: '▾';
}
}
$new-child-height: 25px;
.explorer-node__item-editor,
.explorer-node__new-child {
padding: 1px 10px;
.text-input {
font-size: $item-font-size;
padding: 2px;
height: $new-child-height;
}
}
</style>

View File

@ -0,0 +1,395 @@
<template>
<div class="find-replace" @keydown.esc.stop="onEscape">
<button class="find-replace__close-button button not-tabbable" @click="close()" v-title="'关闭'">
<icon-close></icon-close>
</button>
<div class="find-replace__row">
<input type="text" class="find-replace__text-input find-replace__text-input--find text-input" @keydown.enter="find('forward')" v-model="findText">
<div class="find-replace__find-stats">
{{findPosition}} of {{findCount}}
</div>
<div class="flex flex--row flex--space-between">
<div class="flex flex--row">
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findCaseSensitive}" @click="findCaseSensitive = !findCaseSensitive" title="Case sensitive">Aa</button>
<button class="find-replace__button find-replace__button--find-option button" :class="{'find-replace__button--on': findUseRegexp}" @click="findUseRegexp = !findUseRegexp" title="Regular expression">.<sup></sup></button>
</div>
<div class="flex flex--row">
<button class="find-replace__button button" @click="find('backward')">上一个</button>
<button class="find-replace__button button" @click="find('forward')">下一个</button>
</div>
</div>
</div>
<div v-if="type === 'replace'">
<div class="find-replace__row">
<input type="text" class="find-replace__text-input find-replace__text-input--replace text-input" @keydown.enter="replace" v-model="replaceText">
</div>
<div class="find-replace__row flex flex--row flex--end">
<button class="find-replace__button button" @click="replace">替换</button>
<button class="find-replace__button button" @click="replaceAll">全部替换</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import editorSvc from '../services/editorSvc';
import cledit from '../services/editor/cledit';
import store from '../store';
import EditorClassApplier from './common/EditorClassApplier';
const accessor = (fieldName, setterName) => ({
get() {
return store.state.findReplace[fieldName];
},
set(value) {
store.commit(`findReplace/${setterName}`, value);
},
});
const computedLayoutSetting = key => ({
get() {
return store.getters['data/layoutSettings'][key];
},
set(value) {
store.dispatch('data/patchLayoutSettings', {
[key]: value,
});
},
});
class DynamicClassApplier {
constructor(cssClass, offset, silent) {
this.startMarker = new cledit.Marker(offset.start);
this.endMarker = new cledit.Marker(offset.end);
editorSvc.clEditor.addMarker(this.startMarker);
editorSvc.clEditor.addMarker(this.endMarker);
if (!silent) {
this.classApplier = new EditorClassApplier(
[`find-replace-${this.startMarker.id}`, cssClass],
() => ({
start: this.startMarker.offset,
end: this.endMarker.offset,
}),
);
}
}
clean = () => {
editorSvc.clEditor.removeMarker(this.startMarker);
editorSvc.clEditor.removeMarker(this.endMarker);
if (this.classApplier) {
this.classApplier.stop();
}
}
}
export default {
data: () => ({
findCount: 0,
findPosition: 0,
}),
computed: {
...mapState('findReplace', [
'type',
'lastOpen',
]),
findText: accessor('findText', 'setFindText'),
replaceText: accessor('replaceText', 'setReplaceText'),
findCaseSensitive: computedLayoutSetting('findCaseSensitive'),
findUseRegexp: computedLayoutSetting('findUseRegexp'),
},
methods: {
highlightOccurrences() {
const oldClassAppliers = {};
Object.entries(this.classAppliers).forEach(([, classApplier]) => {
const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;
oldClassAppliers[newKey] = classApplier;
});
const offsetList = [];
this.classAppliers = {};
if (this.state !== 'destroyed' && this.findText) {
try {
this.searchRegex = this.findText;
if (!this.findUseRegexp) {
this.searchRegex = this.searchRegex.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
}
this.replaceRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'm' : 'mi');
this.searchRegex = new RegExp(this.searchRegex, this.findCaseSensitive ? 'gm' : 'gmi');
editorSvc.clEditor.getContent().replace(this.searchRegex, (...params) => {
const match = params[0];
const offset = params[params.length - 2];
offsetList.push({
start: offset,
end: offset + match.length,
});
});
offsetList.forEach((offset, i) => {
const key = `${offset.start}:${offset.end}`;
this.classAppliers[key] = oldClassAppliers[key] || new DynamicClassApplier(
'find-replace-highlighting',
offset,
i > 200,
);
});
} catch (e) {
// Ignore
}
if (this.state !== 'created') {
this.find('selection');
this.state = 'created';
}
}
Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {
if (!this.classAppliers[key]) {
classApplier.clean();
if (classApplier === this.selectedClassApplier) {
this.selectedClassApplier.child.clean();
this.selectedClassApplier = null;
}
}
});
this.findCount = offsetList.length;
},
unselectClassApplier() {
if (this.selectedClassApplier) {
this.selectedClassApplier.child.clean();
this.selectedClassApplier.child = null;
this.selectedClassApplier = null;
}
this.findPosition = 0;
},
find(mode = 'forward') {
const { selectedClassApplier } = this;
this.unselectClassApplier();
const { selectionMgr } = editorSvc.clEditor;
const startOffset = Math.min(selectionMgr.selectionStart, selectionMgr.selectionEnd);
const endOffset = Math.max(selectionMgr.selectionStart, selectionMgr.selectionEnd);
const keys = Object.keys(this.classAppliers);
const finder = checker => (key) => {
if (checker(this.classAppliers[key]) && selectedClassApplier !== this.classAppliers[key]) {
this.selectedClassApplier = this.classAppliers[key];
return true;
}
return false;
};
if (mode === 'backward') {
this.selectedClassApplier = this.classAppliers[keys[keys.length - 1]];
keys.reverse().some(finder(classApplier => classApplier.startMarker.offset <= startOffset));
} else if (mode === 'selection') {
keys.some(finder(classApplier => classApplier.startMarker.offset === startOffset &&
classApplier.endMarker.offset === endOffset));
} else if (mode === 'forward') {
this.selectedClassApplier = this.classAppliers[keys[0]];
keys.some(finder(classApplier => classApplier.endMarker.offset >= endOffset));
}
if (this.selectedClassApplier) {
selectionMgr.setSelectionStartEnd(
this.selectedClassApplier.startMarker.offset,
this.selectedClassApplier.endMarker.offset,
);
this.selectedClassApplier.child = new DynamicClassApplier('find-replace-selection', {
start: this.selectedClassApplier.startMarker.offset,
end: this.selectedClassApplier.endMarker.offset,
});
selectionMgr.updateCursorCoordinates(this.$el.parentNode.clientHeight);
// Deduce the findPosition
Object.keys(this.classAppliers).forEach((key, i) => {
if (this.selectedClassApplier !== this.classAppliers[key]) {
return false;
}
this.findPosition = i + 1;
return true;
});
}
},
replace() {
if (this.searchRegex) {
if (!this.selectedClassApplier) {
this.find();
return;
}
editorSvc.clEditor.replaceAll(
this.replaceRegex,
this.replaceText,
this.selectedClassApplier.startMarker.offset,
);
this.$nextTick(() => this.find());
}
},
replaceAll() {
if (this.searchRegex) {
editorSvc.clEditor.replaceAll(this.searchRegex, this.replaceText);
}
},
close() {
store.commit('findReplace/setType');
},
onEscape() {
editorSvc.clEditor.focus();
},
},
mounted() {
this.classAppliers = {};
// Highlight occurences
this.debouncedHighlightOccurrences = cledit.Utils.debounce(
() => this.highlightOccurrences(),
25,
);
// Refresh highlighting when find text changes or changing options
this.$watch(() => this.findText, this.debouncedHighlightOccurrences);
this.$watch(() => this.findCaseSensitive, this.debouncedHighlightOccurrences);
this.$watch(() => this.findUseRegexp, this.debouncedHighlightOccurrences);
// Refresh highlighting when content changes
editorSvc.clEditor.on('contentChanged', this.debouncedHighlightOccurrences);
// Last open changes trigger focus on text input and find occurence in selection
this.$watch(() => this.lastOpen, () => {
const elt = this.$el.querySelector(`.find-replace__text-input--${this.type}`);
elt.focus();
elt.setSelectionRange(0, this[`${this.type}Text`].length);
// Highlight and find in selection
this.state = null;
this.debouncedHighlightOccurrences();
}, {
immediate: true,
});
// Close on escape
this.onKeyup = (evt) => {
if (evt.which === 27) {
// Esc key
store.commit('findReplace/setType');
}
};
window.addEventListener('keyup', this.onKeyup);
// Unselect class applier when focus is out of the panel
this.onFocusIn = () => this.$el.contains(document.activeElement) ||
setTimeout(() => this.unselectClassApplier(), 15);
window.addEventListener('focusin', this.onFocusIn);
},
destroyed() {
// Unregister listeners
editorSvc.clEditor.off('contentChanged', this.debouncedHighlightOccurrences);
window.removeEventListener('keyup', this.onKeyup);
window.removeEventListener('focusin', this.onFocusIn);
this.state = 'destroyed';
this.debouncedHighlightOccurrences();
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.find-replace {
padding: 0 35px 0 25px;
}
.find-replace__row {
margin: 10px 0;
}
.find-replace__button {
font-size: 15px;
padding: 0 8px;
line-height: 28px;
height: 28px;
}
.find-replace__button--find-option {
padding: 0;
width: 28px;
font-weight: 600;
letter-spacing: -0.025em;
color: rgba(0, 0, 0, 0.25);
text-transform: none;
.app--dark & {
color: rgba(255, 255, 255, 0.25);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.25);
.app--dark & {
color: rgba(255, 255, 255, 0.25);
}
}
}
.find-replace__button--on {
color: rgba(0, 0, 0, 0.67);
.app--dark & {
color: rgba(255, 255, 255, 0.67);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.67);
.app--dark & {
color: rgba(255, 255, 255, 0.67);
}
}
}
.find-replace__text-input {
border: 1px solid transparent;
padding: 2px 5px;
height: 32px;
&:focus {
border-color: $link-color;
}
}
.find-replace__close-button {
position: absolute;
top: 5px;
right: 5px;
width: 25px;
height: 25px;
padding: 2px;
color: rgba(0, 0, 0, 0.5);
.app--dark & {
color: rgba(255, 255, 255, 0.5);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.75);
.app--dark & {
color: rgba(255, 255, 255, 0.75);
}
}
}
.find-replace__find-stats {
text-align: right;
font-size: 0.75em;
opacity: 0.6;
}
.find-replace-highlighting {
background-color: $highlighting-color;
color: $editor-color-light !important;
.app--dark & {
background-color: $dark-highlighting-color;
}
}
.find-replace-selection {
background-color: $selection-highlighting-color;
}
</style>

245
src/components/Layout.vue Normal file
View File

@ -0,0 +1,245 @@
<template>
<div class="layout" :class="{'layout--revision': revisionContent}">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}">
<div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :aria-hidden="!styles.showExplorer" :style="{width: styles.layoutOverflow ? '100%' : constants.explorerWidth + 'px'}">
<explorer></explorer>
</div>
<div class="layout__panel flex flex--column" tour-step-anchor="welcome,end" :style="{width: styles.innerWidth + 'px'}">
<div class="layout__panel layout__panel--navigation-bar" v-show="styles.showNavigationBar" :style="{height: constants.navigationBarHeight + 'px'}">
<navigation-bar></navigation-bar>
</div>
<div class="layout__panel flex flex--row" :style="{height: styles.innerHeight + 'px'}">
<div class="layout__panel layout__panel--editor" :class="editTheme" v-show="styles.showEditor" :style="{width: (styles.editorWidth + styles.editorGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<div class="gutter__background" v-if="styles.editorGutterWidth" :style="{width: styles.editorGutterWidth + 'px'}"></div>
</div>
<editor></editor>
<editor-in-page-buttons v-if="editorShowInPageButtons"></editor-in-page-buttons>
<div class="gutter" :style="{left: styles.editorGutterLeft + 'px'}">
<sticky-comment v-if="styles.editorGutterWidth && stickyComment === 'top'"></sticky-comment>
<current-discussion v-if="styles.editorGutterWidth"></current-discussion>
</div>
</div>
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{width: constants.buttonBarWidth + 'px'}">
<button-bar></button-bar>
</div>
<div class="layout__panel layout__panel--preview" v-show="styles.showPreview" :style="{width: (styles.previewWidth + styles.previewGutterWidth) + 'px', fontSize: styles.fontSize + 'px'}">
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<div class="gutter__background" v-if="styles.previewGutterWidth" :style="{width: styles.previewGutterWidth + 'px'}"></div>
</div>
<preview></preview>
<preview-in-page-buttons></preview-in-page-buttons>
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<sticky-comment v-if="styles.previewGutterWidth && stickyComment === 'top'"></sticky-comment>
<current-discussion v-if="styles.previewGutterWidth"></current-discussion>
</div>
</div>
<div class="layout__panel layout__panel--find-replace" v-if="showFindReplace">
<find-replace></find-replace>
</div>
</div>
<div class="layout__panel layout__panel--status-bar" v-show="styles.showStatusBar" :style="{height: constants.statusBarHeight + 'px'}">
<status-bar></status-bar>
</div>
</div>
<div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px'}">
<side-bar></side-bar>
</div>
</div>
<tour v-if="!light && !layoutSettings.welcomeTourFinished"></tour>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import NavigationBar from './NavigationBar';
import ButtonBar from './ButtonBar';
import StatusBar from './StatusBar';
import Explorer from './Explorer';
import SideBar from './SideBar';
import Editor from './Editor';
import Preview from './Preview';
import Tour from './Tour';
import EditorInPageButtons from './EditorInPageButtons';
import PreviewInPageButtons from './PreviewInPageButtons';
import StickyComment from './gutters/StickyComment';
import CurrentDiscussion from './gutters/CurrentDiscussion';
import FindReplace from './FindReplace';
import editorSvc from '../services/editorSvc';
import markdownConversionSvc from '../services/markdownConversionSvc';
import store from '../store';
export default {
components: {
NavigationBar,
ButtonBar,
StatusBar,
Explorer,
SideBar,
Editor,
Preview,
Tour,
EditorInPageButtons,
PreviewInPageButtons,
StickyComment,
CurrentDiscussion,
FindReplace,
},
computed: {
...mapState([
'light',
]),
...mapState('content', [
'revisionContent',
]),
...mapState('discussion', [
'stickyComment',
]),
...mapGetters('layout', [
'constants',
'styles',
]),
...mapGetters('data', [
'layoutSettings',
]),
...mapGetters('theme', [
'currEditTheme',
]),
editTheme() {
return `edit-theme--${this.currEditTheme || 'default'}`;
},
showFindReplace() {
return !!store.state.findReplace.type;
},
editorShowInPageButtons() {
return store.getters['data/computedSettings'].editor.showInPageButtons;
},
},
methods: {
...mapActions('layout', [
'updateBodySize',
]),
saveSelection: () => editorSvc.saveSelection(true),
},
created() {
markdownConversionSvc.init(); // Needs to be inited before mount
this.updateBodySize();
window.addEventListener('resize', this.updateBodySize);
window.addEventListener('keyup', this.saveSelection);
window.addEventListener('mouseup', this.saveSelection);
window.addEventListener('focusin', this.saveSelection);
window.addEventListener('contextmenu', this.saveSelection);
},
mounted() {
const editorElt = this.$el.querySelector('.editor__inner');
const previewElt = this.$el.querySelector('.preview__inner-2');
const tocElt = this.$el.querySelector('.toc__inner');
editorSvc.init(editorElt, previewElt, tocElt);
// Focus on the editor every time reader mode is disabled
const focus = () => {
if (this.styles.showEditor) {
editorSvc.clEditor.focus();
}
};
setTimeout(focus, 100);
this.$watch(() => this.styles.showEditor, focus);
},
destroyed() {
window.removeEventListener('resize', this.updateStyle);
window.removeEventListener('keyup', this.saveSelection);
window.removeEventListener('mouseup', this.saveSelection);
window.removeEventListener('focusin', this.saveSelection);
window.removeEventListener('contextmenu', this.saveSelection);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.layout {
position: absolute;
width: 100%;
height: 100%;
}
.layout__panel {
position: relative;
width: 100%;
height: 100%;
flex: none;
overflow: hidden;
}
.layout__panel--navigation-bar {
background-color: $navbar-bg;
}
.layout__panel--status-bar {
background-color: #007acc;
}
.layout__panel--editor {
background-color: $editor-background-light;
.app--dark & {
background-color: $editor-background-dark;
}
.gutter__background,
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
background-color: mix(#000, $editor-background-light, 6.7%);
.app--dark & {
background-color: mix(#fff, $editor-background-dark, 6.7%);
}
}
}
$preview-background-light: #f3f3f3;
$preview-background-dark: #444;
.layout__panel--preview,
.layout__panel--button-bar {
background-color: $preview-background-light;
.app--dark & {
background-color: $preview-background-dark;
}
}
.layout__panel--preview {
.gutter__background,
.comment-list__current-discussion,
.sticky-comment,
.current-discussion {
background-color: mix(#000, $preview-background-light, 6.7%);
}
}
.layout__panel--explorer,
.layout__panel--side-bar {
background-color: #ddd;
.app--dark & {
background-color: #383c4a;
}
}
.layout__panel--find-replace {
background-color: #e6e6e6;
position: absolute;
left: 0;
bottom: 0;
width: 300px;
height: auto;
border-top-right-radius: $border-radius-base;
.app--dark & {
background-color: #4d5160;
}
}
</style>

489
src/components/Modal.vue Normal file
View File

@ -0,0 +1,489 @@
<template>
<div class="modal" v-if="config" @keydown.esc.stop="onEscape" @keydown.tab="onTab" @focusin="onFocusInOut" @focusout="onFocusInOut">
<!-- <div class="modal__sponsor-banner" v-if="!isSponsor">
StackEdit is <a class="not-tabbable" target="_blank" href="https://github.com/mafgwo/stackedit/">open source</a>, please consider
<a class="not-tabbable" href="javascript:void(0)" @click="sponsor">sponsoring</a> for just $5.
</div> -->
<component v-if="currentModalComponent" :is="currentModalComponent"></component>
<modal-inner v-else aria-label="Dialog">
<div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
<div class="modal__button-bar">
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
<button class="button button--resolve" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
<button v-for="(item, idx) in (simpleModal.resolveArray || [])" class="button button--resolve" @click="config.resolve(item.value)">{{item.text}}</button>
</div>
</modal-inner>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import simpleModals from '../data/simpleModals';
import editorSvc from '../services/editorSvc';
import syncSvc from '../services/syncSvc';
import giteeHelper from '../services/providers/helpers/giteeHelper';
import store from '../store';
import ModalInner from './modals/common/ModalInner';
import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal';
import AboutModal from './modals/AboutModal';
import HtmlExportModal from './modals/HtmlExportModal';
import PdfExportModal from './modals/PdfExportModal';
import PandocExportModal from './modals/PandocExportModal';
import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal';
import SyncManagementModal from './modals/SyncManagementModal';
import PublishManagementModal from './modals/PublishManagementModal';
import WorkspaceManagementModal from './modals/WorkspaceManagementModal';
import AccountManagementModal from './modals/AccountManagementModal';
import BadgeManagementModal from './modals/BadgeManagementModal';
import SponsorModal from './modals/SponsorModal';
import CommitMessageModal from './modals/CommitMessageModal';
import WorkspaceImgPathModal from './modals/WorkspaceImgPathModal';
import ChatGptModal from './modals/ChatGptModal';
// Providers
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
import GoogleDriveAccountModal from './modals/providers/GoogleDriveAccountModal';
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
import GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
import DropboxAccountModal from './modals/providers/DropboxAccountModal';
import DropboxSaveModal from './modals/providers/DropboxSaveModal';
import DropboxPublishModal from './modals/providers/DropboxPublishModal';
import GithubAccountModal from './modals/providers/GithubAccountModal';
import GithubOpenModal from './modals/providers/GithubOpenModal';
import GithubSaveModal from './modals/providers/GithubSaveModal';
import GithubWorkspaceModal from './modals/providers/GithubWorkspaceModal';
import GithubPublishModal from './modals/providers/GithubPublishModal';
import GithubImgStorageModal from './modals/providers/GithubImgStorageModal';
import GistSyncModal from './modals/providers/GistSyncModal';
import GistPublishModal from './modals/providers/GistPublishModal';
import GiteeAccountModal from './modals/providers/GiteeAccountModal';
import GiteeOpenModal from './modals/providers/GiteeOpenModal';
import GiteeSaveModal from './modals/providers/GiteeSaveModal';
import GiteeWorkspaceModal from './modals/providers/GiteeWorkspaceModal';
import GiteePublishModal from './modals/providers/GiteePublishModal';
import GiteeGistSyncModal from './modals/providers/GiteeGistSyncModal';
import GiteeGistPublishModal from './modals/providers/GiteeGistPublishModal';
import GitlabAccountModal from './modals/providers/GitlabAccountModal';
import GitlabOpenModal from './modals/providers/GitlabOpenModal';
import GitlabPublishModal from './modals/providers/GitlabPublishModal';
import GitlabSaveModal from './modals/providers/GitlabSaveModal';
import GitlabWorkspaceModal from './modals/providers/GitlabWorkspaceModal';
import GiteaAccountModal from './modals/providers/GiteaAccountModal';
import GiteaOpenModal from './modals/providers/GiteaOpenModal';
import GiteaPublishModal from './modals/providers/GiteaPublishModal';
import GiteaSaveModal from './modals/providers/GiteaSaveModal';
import GiteaWorkspaceModal from './modals/providers/GiteaWorkspaceModal';
import GiteaImgStorageModal from './modals/providers/GiteaImgStorageModal';
import WordpressPublishModal from './modals/providers/WordpressPublishModal';
import BloggerPublishModal from './modals/providers/BloggerPublishModal';
import BloggerPagePublishModal from './modals/providers/BloggerPagePublishModal';
import ZendeskAccountModal from './modals/providers/ZendeskAccountModal';
import ZendeskPublishModal from './modals/providers/ZendeskPublishModal';
import CouchdbWorkspaceModal from './modals/providers/CouchdbWorkspaceModal';
import CouchdbCredentialsModal from './modals/providers/CouchdbCredentialsModal';
import SmmsAccountModal from './modals/providers/SmmsAccountModal';
import CustomAccountModal from './modals/providers/CustomAccountModal';
const getTabbables = container => container.querySelectorAll('a[href], button, .textfield, input[type=checkbox]')
// Filter enabled and visible element
.cl_filter(el => !el.disabled && el.offsetParent !== null && !el.classList.contains('not-tabbable'));
export default {
components: {
ModalInner,
FilePropertiesModal,
SettingsModal,
TemplatesModal,
AboutModal,
HtmlExportModal,
PdfExportModal,
PandocExportModal,
LinkModal,
ImageModal,
SyncManagementModal,
PublishManagementModal,
WorkspaceManagementModal,
AccountManagementModal,
BadgeManagementModal,
SponsorModal,
CommitMessageModal,
WorkspaceImgPathModal,
ChatGptModal,
// Providers
GooglePhotoModal,
GoogleDriveAccountModal,
GoogleDriveSaveModal,
GoogleDriveWorkspaceModal,
GoogleDrivePublishModal,
DropboxAccountModal,
DropboxSaveModal,
DropboxPublishModal,
GithubAccountModal,
GithubOpenModal,
GithubSaveModal,
GithubWorkspaceModal,
GithubPublishModal,
GithubImgStorageModal,
GistSyncModal,
GistPublishModal,
GiteeAccountModal,
GiteeOpenModal,
GiteeSaveModal,
GiteeWorkspaceModal,
GiteePublishModal,
GiteeGistSyncModal,
GiteeGistPublishModal,
GitlabAccountModal,
GitlabOpenModal,
GitlabPublishModal,
GitlabSaveModal,
GitlabWorkspaceModal,
GiteaAccountModal,
GiteaOpenModal,
GiteaPublishModal,
GiteaSaveModal,
GiteaWorkspaceModal,
GiteaImgStorageModal,
WordpressPublishModal,
BloggerPublishModal,
BloggerPagePublishModal,
ZendeskAccountModal,
ZendeskPublishModal,
CouchdbWorkspaceModal,
CouchdbCredentialsModal,
SmmsAccountModal,
CustomAccountModal,
},
computed: {
...mapGetters([
'isSponsor',
]),
...mapGetters('modal', [
'config',
]),
currentModalComponent() {
if (this.config.type) {
let componentName = this.config.type[0].toUpperCase();
componentName += this.config.type.slice(1);
componentName += 'Modal';
if (this.$options.components[componentName]) {
return componentName;
}
}
return null;
},
simpleModal() {
return simpleModals[this.config.type] || {};
},
},
methods: {
async sponsor() {
try {
if (!store.getters['workspace/sponsorToken']) {
// User has to sign in
await store.dispatch('modal/open', 'signInForSponsorship');
await giteeHelper.signin();
await syncSvc.afterSignIn();
syncSvc.requestSync();
}
if (!store.getters.isSponsor) {
await store.dispatch('modal/open', 'sponsor');
}
} catch (e) { /* cancel */ }
},
onEscape() {
this.config.reject();
editorSvc.clEditor.focus();
},
onTab(evt) {
const tabbables = getTabbables(this.$el);
const firstTabbable = tabbables[0];
const lastTabbable = tabbables[tabbables.length - 1];
if (evt.shiftKey && firstTabbable === evt.target) {
evt.preventDefault();
lastTabbable.focus();
} else if (!evt.shiftKey && lastTabbable === evt.target) {
evt.preventDefault();
firstTabbable.focus();
}
},
onFocusInOut(evt) {
const { parentNode } = evt.target;
if (parentNode && parentNode.parentNode) {
// Focus effect
if (parentNode.classList.contains('form-entry__field')
&& parentNode.parentNode.classList.contains('form-entry')) {
parentNode.parentNode.classList.toggle(
'form-entry--focused',
evt.type === 'focusin',
);
}
}
},
},
mounted() {
this.$watch(
() => this.config,
(isOpen) => {
if (isOpen) {
const tabbables = getTabbables(this.$el);
if (tabbables[0]) {
tabbables[0].focus();
}
}
},
{ immediate: true },
);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.modal {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(160, 160, 160, 0.5);
overflow: auto;
p {
line-height: 1.5;
}
}
.modal__sponsor-banner {
position: fixed;
z-index: 1;
width: 100%;
color: darken($error-color, 10%);
background-color: transparentize(lighten($error-color, 33%), 0.075);
font-size: 0.9em;
line-height: 1.33;
text-align: center;
padding: 0.25em 1em;
}
.modal__inner-1 {
margin: 0 auto;
width: 100%;
min-width: 320px;
max-width: 480px;
}
.modal__inner-2 {
margin: 40px 10px 100px;
background-color: #f8f8f8;
padding: 50px 50px 40px;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
.app--dark & {
background-color: #383c4a;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: $border-radius-base;
width: 100%;
background-image: linear-gradient(to left, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: $border-radius-base;
width: 100%;
background-image: linear-gradient(to right, #ffd700, #ffd700 23%, #a5c700 27%, #a5c700 48%, #ff8a00 52%, #ff8a00 73%, #66aefd 77%);
}
}
.modal__content > :first-child,
.modal__content > .modal__image:first-child + * {
margin-top: 0;
}
.modal__image {
float: left;
width: 60px;
height: 60px;
margin: 1.5em 1.2em 0.5em 0;
& + *::after {
content: '';
display: block;
clear: both;
}
}
.modal__title {
font-weight: bold;
font-size: 1.5rem;
line-height: 1.4;
margin-top: 2.5rem;
}
.modal__sub-title {
opacity: 0.6;
font-size: 0.75rem;
margin-bottom: 1.5rem;
}
.modal__error {
color: #de2c00;
}
.modal__info {
background-color: $info-bg;
border-radius: $border-radius-base;
margin: 1.2em 0;
padding: 0.75em 1.25em;
font-size: 0.95em;
line-height: 1.6;
pre {
line-height: 1.5;
}
}
.modal__info--multiline {
padding-top: 0.1em;
padding-bottom: 0.1em;
}
.modal__button-bar {
margin-top: 2rem;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.form-entry {
margin: 1em 0;
}
.form-entry__label {
display: block;
font-size: 0.9rem;
color: #808080;
.form-entry--focused & {
color: darken($link-color, 10%);
}
.form-entry--error & {
color: darken($error-color, 10%);
}
}
.form-entry__label-info {
font-size: 0.75rem;
}
.form-entry__field {
border: 1px solid #b0b0b0;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
.form-entry--focused & {
border-color: $link-color;
box-shadow: 0 0 0 2.5px transparentize($link-color, 0.67);
}
.form-entry--error & {
border-color: $error-color;
box-shadow: 0 0 0 2.5px transparentize($error-color, 0.67);
}
}
.form-entry__actions {
text-align: right;
margin: 0.25em;
}
.form-entry__button {
width: 38px;
height: 38px;
padding: 6px;
display: inline-block;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
.form-entry__radio,
.form-entry__checkbox {
margin: 0.25em 1em;
input {
margin-right: 0.25em;
}
}
.form-entry__info {
font-size: 0.75em;
opacity: 0.67;
line-height: 1.4;
margin: 0.25em 0;
}
.tabs {
border-bottom: 1px solid $hr-color;
margin: 1em 0 2em;
&::after {
content: '';
display: block;
clear: both;
}
}
.tabs__tab {
width: 50%;
float: left;
text-align: center;
line-height: 1.4;
font-weight: 400;
font-size: 1.1em;
}
.tabs__tab > a {
width: 100%;
text-decoration: none;
padding: 0.67em 0.33em;
cursor: pointer;
border-bottom: 2px solid transparent;
border-top-left-radius: $border-radius-base;
border-top-right-radius: $border-radius-base;
color: $link-color;
&:hover,
&:focus {
background-color: rgba(0, 0, 0, 0.05);
}
}
.tabs__tab--active > a {
border-bottom: 2px solid $link-color;
color: inherit;
}
</style>

View File

@ -0,0 +1,543 @@
<template>
<nav class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor && !revisionContent, 'navigation-bar--light': light}">
<!-- Explorer -->
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button navigation-bar__button--close button" v-if="light" @click="close()" v-title="'关闭StackEdit'"><icon-check-circle></icon-check-circle></button>
<button class="navigation-bar__button navigation-bar__button--explorer-toggler button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'切换资源管理器'"><icon-folder></icon-folder></button>
</div>
<!-- Side bar -->
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
<button class="navigation-bar__button navigation-bar__button--theme button" v-title="'切换主题'" tour-step-anchor="theme" @click="switchTheme"><icon-switch-theme></icon-switch-theme></button>
<a class="navigation-bar__button navigation-bar__button--stackedit button" v-if="light" href="app" target="_blank" v-title="'打开StackEdit'"><icon-provider provider-id="stackedit"></icon-provider></a>
<button class="navigation-bar__button navigation-bar__button--stackedit button" v-else tour-step-anchor="menu" @click="toggleSideBar()" v-title="'切换侧边栏'"><icon-provider provider-id="stackedit"></icon-provider></button>
</div>
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--title flex flex--row">
<!-- Spinner -->
<div class="navigation-bar__spinner">
<div v-if="!offline && showSpinner" class="spinner"></div>
<icon-sync-off v-if="offline"></icon-sync-off>
</div>
<!-- Title -->
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
<div class="navigation-bar__title navigation-bar__title--text text-input" :style="{width: titleWidth + 'px'}">{{title}}</div>
<input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" :style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keydown.enter="submitTitle(false)" @keydown.esc.stop="submitTitle(true)" @mouseenter="titleHover = true" @mouseleave="titleHover = false" v-model="title">
<!-- Sync/Publish -->
<div class="flex flex--row" :class="{'navigation-bar__hidden': styles.hideLocations}">
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank" v-title="'同步位置'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
<button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync" v-title="'立即同步'"><icon-sync></icon-sync></button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank" v-title="'发布位置'"><icon-provider :provider-id="location.providerId"></icon-provider></a>
<button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish" v-title="'立即发布'"><icon-upload></icon-upload></button>
</div>
<!-- Revision -->
<div class="flex flex--row" v-if="revisionContent">
<button class="navigation-bar__button navigation-bar__button--revision navigation-bar__button--restore button" @click="restoreRevision">恢复</button>
<button class="navigation-bar__button navigation-bar__button--revision button" @click="setRevisionContent()" v-title="'关闭修订'"><icon-close></icon-close></button>
</div>
</div>
<div class="navigation-bar__inner navigation-bar__inner--edit-pagedownButtons">
<button class="navigation-bar__button button" @click="undo" v-title="'回退'" :disabled="!canUndo"><icon-undo></icon-undo></button>
<button class="navigation-bar__button button" @click="redo" v-title="'重做'" :disabled="!canRedo"><icon-redo></icon-redo></button>
<div v-for="button in pagedownButtons" :key="button.method">
<button class="navigation-bar__button button" v-if="button.method" @click="pagedownClick(button.method)" v-title="button.titleWithShortcut">
<component :is="button.iconClass"></component>
</button>
<div class="navigation-bar__spacer" v-else></div>
</div>
</div>
</nav>
</template>
<script>
import { mapState, mapMutations, mapGetters, mapActions } from 'vuex';
import editorSvc from '../services/editorSvc';
import syncSvc from '../services/syncSvc';
import publishSvc from '../services/publishSvc';
import animationSvc from '../services/animationSvc';
import tempFileSvc from '../services/tempFileSvc';
import utils from '../services/utils';
import pagedownButtons from '../data/pagedownButtons';
import store from '../store';
import workspaceSvc from '../services/workspaceSvc';
import badgeSvc from '../services/badgeSvc';
// According to mousetrap
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
const getShortcut = (method) => {
let result = '';
Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {
if (`${shortcut.method || shortcut}` === method) {
result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
if (key === 'mod') {
return mod;
}
// Capitalize
return key && `${key[0].toUpperCase()}${key.slice(1)}`;
}).join('+');
}
return result;
});
return result && ` ${result}`;
};
export default {
data: () => ({
mounted: false,
title: '',
titleFocus: false,
titleHover: false,
}),
computed: {
...mapState([
'light',
'offline',
]),
...mapState('queue', [
'isSyncRequested',
'isPublishRequested',
'currentLocation',
]),
...mapState('layout', [
'canUndo',
'canRedo',
]),
...mapState('content', [
'revisionContent',
]),
...mapGetters('layout', [
'styles',
]),
...mapGetters('syncLocation', {
syncLocations: 'current',
}),
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
pagedownButtons() {
const buttonShowObj = store.getters['data/computedSettings'].editor.headButtons;
return pagedownButtons.filter(it => buttonShowObj[it.method]).map(button => ({
...button,
titleWithShortcut: `${button.title}${getShortcut(button.method)}`,
iconClass: `icon-${button.icon}`,
}));
},
isSyncPossible() {
return store.getters['workspace/syncToken'] ||
store.getters['syncLocation/current'].length;
},
showSpinner() {
return !store.state.queue.isEmpty;
},
titleWidth() {
if (!this.mounted) {
return 0;
}
this.titleFakeElt.textContent = this.title;
const width = this.titleFakeElt.getBoundingClientRect().width + 2; // 2px for the caret
return Math.min(width, this.styles.titleMaxWidth);
},
titleScrolling() {
const result = this.titleHover && !this.titleFocus;
if (this.titleInputElt) {
if (result) {
const scrollLeft = this.titleInputElt.scrollWidth - this.titleInputElt.offsetWidth;
animationSvc.animate(this.titleInputElt)
.scrollLeft(scrollLeft)
.duration(scrollLeft * 10)
.easing('inOut')
.start();
} else {
animationSvc.animate(this.titleInputElt)
.scrollLeft(0)
.start();
}
}
return result;
},
editCancelTrigger() {
const current = store.getters['file/current'];
return utils.serializeObject([
current.id,
current.name,
]);
},
},
methods: {
...mapMutations('content', [
'setRevisionContent',
]),
...mapActions('content', [
'restoreRevision',
]),
...mapActions('data', [
'toggleExplorer',
'toggleSideBar',
]),
undo() {
return editorSvc.clEditor.undoMgr.undo();
},
redo() {
return editorSvc.clEditor.undoMgr.redo();
},
requestSync() {
if (this.isSyncPossible && !this.isSyncRequested) {
syncSvc.requestSync(true);
}
},
requestPublish() {
if (this.publishLocations.length && !this.isPublishRequested) {
publishSvc.requestPublish();
}
},
switchTheme() {
store.dispatch('data/switchThemeSetting');
},
pagedownClick(name) {
if (store.getters['content/isCurrentEditable']) {
const text = editorSvc.clEditor.getContent();
editorSvc.pagedownEditor.uiManager.doClick(name);
if (text !== editorSvc.clEditor.getContent()) {
badgeSvc.addBadge('formatButtons');
}
}
},
async editTitle(toggle) {
this.titleFocus = toggle;
if (toggle) {
this.titleInputElt.setSelectionRange(0, this.titleInputElt.value.length);
} else {
const title = this.title.trim();
this.title = store.getters['file/current'].name;
if (title && this.title !== title) {
try {
await workspaceSvc.storeItem({
...store.getters['file/current'],
name: title,
});
badgeSvc.addBadge('editCurrentFileName');
} catch (e) {
// Cancel
}
}
}
},
submitTitle(reset) {
if (reset) {
this.title = '';
}
this.titleInputElt.blur();
},
close() {
tempFileSvc.close();
},
},
created() {
this.$watch(
() => this.editCancelTrigger,
() => {
this.title = '';
this.editTitle(false);
},
{ immediate: true },
);
},
mounted() {
this.titleFakeElt = this.$el.querySelector('.navigation-bar__title--fake');
this.titleInputElt = this.$el.querySelector('.navigation-bar__title--input');
this.mounted = true;
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.navigation-bar {
position: absolute;
width: 100%;
height: 100%;
padding-top: 4px;
overflow: hidden;
}
.navigation-bar__hidden {
display: none;
}
.navigation-bar__inner--left {
float: left;
&.navigation-bar__inner--button {
margin-right: 12px;
}
}
.navigation-bar__inner--right {
float: right;
/* prevent from seeing wrapped pagedownButtons */
margin-bottom: 20px;
}
.navigation-bar__inner--button {
margin: 0 4px;
}
.navigation-bar__inner--edit-pagedownButtons {
margin-left: 15px;
.navigation-bar__button,
.navigation-bar__spacer {
float: left;
}
}
.navigation-bar__inner--title * {
flex: none;
}
.navigation-bar__button,
.navigation-bar__spacer {
height: 36px;
padding: 0 4px;
/* prevent from seeing wrapped pagedownButtons */
margin-bottom: 20px;
}
.navigation-bar__button {
width: 34px;
padding: 0 7px;
transition: opacity 0.25s;
.navigation-bar__inner--button & {
padding: 0 4px;
width: 38px;
&.navigation-bar__button--theme {
width: 34px;
padding: 0 7px;
opacity: 0.85;
&:active,
&:focus,
&:hover {
opacity: 1;
}
}
&.navigation-bar__button--stackedit {
opacity: 0.85;
&:active,
&:focus,
&:hover {
opacity: 1;
}
}
}
}
.navigation-bar__button--revision {
width: 38px;
&:first-child {
margin-left: 10px;
}
&:last-child {
margin-right: 10px;
}
}
.navigation-bar__button--restore {
width: auto;
}
.navigation-bar__title {
margin: 0 4px;
font-size: 21px;
.layout--revision & {
position: absolute;
left: -9999px;
}
}
.navigation-bar__title,
.navigation-bar__button {
display: inline-block;
color: $navbar-color;
background-color: transparent;
}
.navigation-bar__button--sync,
.navigation-bar__button--publish {
padding: 0 6px;
margin: 0 5px;
}
.navigation-bar__button[disabled] {
&,
&:active,
&:focus,
&:hover {
color: $navbar-color;
}
}
.navigation-bar__title--input,
.navigation-bar__button {
&:active,
&:focus,
&:hover {
color: $navbar-hover-color;
background-color: $navbar-hover-background;
}
}
.navigation-bar__button--location {
width: 20px;
height: 20px;
border-radius: 10px;
padding: 2px;
margin-top: 8px;
opacity: 0.5;
background-color: rgba(255, 255, 255, 0.2);
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.2);
}
}
.navigation-bar__button--blink {
animation: blink 1s linear infinite;
}
.navigation-bar__title--fake {
position: absolute;
left: -9999px;
width: auto;
white-space: pre-wrap;
}
.navigation-bar__title--text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.navigation-bar--editor & {
display: none;
}
}
.navigation-bar__title--input,
.navigation-bar__inner--edit-pagedownButtons {
display: none;
.navigation-bar--editor & {
display: block;
}
}
.navigation-bar__button {
display: none;
.navigation-bar__inner--button &,
.navigation-bar--editor & {
display: inline-block;
}
}
.navigation-bar__button--revision {
display: inline-block;
}
.navigation-bar__button--close {
color: lighten($link-color, 15%);
&:active,
&:focus,
&:hover {
color: lighten($link-color, 25%);
}
}
.navigation-bar__title--input {
cursor: pointer;
&.navigation-bar__title--focus {
cursor: text;
}
.navigation-bar--light & {
display: none;
}
}
$r: 10px;
$d: $r * 2;
$b: $d/10;
$t: 3000ms;
.navigation-bar__spinner {
width: 24px;
margin: 7px 0 0 8px;
.icon {
width: 24px;
height: 24px;
color: transparentize($error-color, 0.5);
}
}
.spinner {
width: $d;
height: $d;
display: block;
position: relative;
border: $b solid transparentize($navbar-color, 0.5);
border-radius: 50%;
margin: 2px;
&::before,
&::after {
content: "";
position: absolute;
display: block;
width: $b;
background-color: $navbar-color;
border-radius: $b * 0.5;
transform-origin: 50% 0;
}
&::before {
height: $r * 0.4;
left: $r - $b * 1.5;
top: 50%;
animation: spin $t linear infinite;
}
&::after {
height: $r * 0.6;
left: $r - $b * 1.5;
top: 50%;
animation: spin $t/4 linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes blink {
50% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="notification">
<div class="notification__item flex flex--row flex--align-center" v-for="(item, idx) in items" :key="idx">
<div class="notification__icon flex flex--column flex--center">
<icon-alert v-if="item.type === 'error'"></icon-alert>
<icon-check-circle v-else-if="item.type === 'badge'"></icon-check-circle>
<icon-information v-else></icon-information>
</div>
<div class="notification__content">
{{item.content}}
</div>
<button class="notification__button button" v-if="item.type === 'confirm'" @click="item.reject">
</button>
<button class="notification__button button" v-if="item.type === 'confirm'" @click="item.resolve">
</button>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: mapState('notification', [
'items',
]),
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.notification {
position: absolute;
bottom: 0;
right: 0;
width: 100%;
max-width: 340px;
}
.notification__item {
margin: 10px;
padding: 10px 15px;
line-height: 1.4;
background-color: #000;
color: #fff;
font-size: 0.9em;
border-radius: $border-radius-base;
}
.notification__icon {
height: 20px;
width: 20px;
margin-right: 12px;
flex: none;
}
.notification__button {
color: $navbar-color;
padding: 8px;
flex: none;
&:active,
&:focus,
&:hover {
color: $navbar-hover-color;
background-color: $navbar-hover-background;
}
}
</style>

181
src/components/Preview.vue Normal file
View File

@ -0,0 +1,181 @@
<template>
<div class="preview">
<div class="preview__inner-1" @click="onClick" @scroll="onScroll">
<div class="preview__inner-2" :class="previewTheme" :style="{padding: styles.previewPadding}">
</div>
<div class="gutter" :style="{left: styles.previewGutterLeft + 'px'}">
<comment-list v-if="styles.previewGutterWidth"></comment-list>
<preview-new-discussion-button v-if="!isCurrentTemp"></preview-new-discussion-button>
</div>
</div>
<div v-if="!styles.showEditor" class="preview__corner">
<button class="preview__button button" @click="toggleEditor(true)" v-title="'编辑文件'">
<icon-pen></icon-pen>
</button>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import CommentList from './gutters/CommentList';
import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';
import store from '../store';
const appUri = `${window.location.protocol}//${window.location.host}`;
export default {
components: {
CommentList,
PreviewNewDiscussionButton,
},
data: () => ({
previewTop: true,
}),
computed: {
...mapGetters('file', [
'isCurrentTemp',
]),
...mapGetters('theme', [
'currPreviewTheme',
]),
...mapGetters('layout', [
'styles',
]),
previewTheme() {
return `preview-theme--${this.currPreviewTheme || 'default'}`;
},
},
methods: {
...mapActions('data', [
'toggleEditor',
]),
onClick(evt) {
let elt = evt.target;
while (elt !== this.$el) {
if (elt.href && elt.href.match(/^https?:\/\//)
&& (!elt.hash || elt.href.slice(0, appUri.length) !== appUri)) {
evt.preventDefault();
const wnd = window.open(elt.href, '_blank');
wnd.focus();
return;
}
elt = elt.parentNode;
}
},
onScroll(evt) {
this.previewTop = evt.target.scrollTop < 10;
},
},
mounted() {
const previewElt = this.$el.querySelector('.preview__inner-2');
const onDiscussionEvt = cb => (evt) => {
let elt = evt.target;
while (elt && elt !== previewElt) {
if (elt.discussionId) {
cb(elt.discussionId);
return;
}
elt = elt.parentNode;
}
};
const classToggler = toggle => (discussionId) => {
previewElt.getElementsByClassName(`discussion-preview-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.toggle('discussion-preview-highlighting--hover', toggle));
document.getElementsByClassName(`comment--discussion-${discussionId}`)
.cl_each(elt => elt.classList.toggle('comment--hover', toggle));
};
previewElt.addEventListener('mouseover', onDiscussionEvt(classToggler(true)));
previewElt.addEventListener('mouseout', onDiscussionEvt(classToggler(false)));
previewElt.addEventListener('click', onDiscussionEvt((discussionId) => {
store.commit('discussion/setCurrentDiscussionId', discussionId);
}));
this.$watch(
() => store.state.discussion.currentDiscussionId,
(discussionId, oldDiscussionId) => {
if (oldDiscussionId) {
previewElt.querySelectorAll(`.discussion-preview-highlighting--${oldDiscussionId}`)
.cl_each(elt => elt.classList.remove('discussion-preview-highlighting--selected'));
}
if (discussionId) {
previewElt.querySelectorAll(`.discussion-preview-highlighting--${discussionId}`)
.cl_each(elt => elt.classList.add('discussion-preview-highlighting--selected'));
}
},
);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.preview,
.preview__inner-1 {
position: absolute;
width: 100%;
height: 100%;
}
.preview__inner-1 {
overflow: auto;
}
.preview__inner-2 {
margin: 0;
}
.preview__inner-2 > :first-child > :first-child {
margin-top: 0;
}
$corner-size: 110px;
.preview__corner {
position: absolute;
top: 0;
right: 0;
&::before {
content: '';
position: absolute;
right: 0;
border-top: $corner-size solid rgba(0, 0, 0, 0.075);
border-left: $corner-size solid transparent;
pointer-events: none;
.app--dark & {
border-top-color: rgba(255, 255, 255, 0.075);
}
}
}
.preview__button {
position: absolute;
top: 15px;
right: 15px;
width: 40px;
height: 40px;
padding: 5px;
color: rgba(0, 0, 0, 0.25);
.app--dark & {
color: rgba(255, 255, 255, 0.25);
}
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.33);
background-color: transparent;
.app--dark & {
color: rgba(255, 255, 255, 0.33);
}
}
}
</style>

View File

@ -0,0 +1,212 @@
<template>
<div class="preview-in-page-buttons">
<ul>
<li class="before">
<icon-ellipsis></icon-ellipsis>
</li>
<li title="分享">
<a href="javascript:void(0)" @click="share"><icon-share></icon-share></a>
</li>
<li title="切换预览主题">
<dropdown-menu :selected="selectedTheme" :options="allThemes" :closeOnItemClick="false" @change="changeTheme">
<icon-select-theme></icon-select-theme>
</dropdown-menu>
</li>
<li title="Markdown语法帮助">
<a href="javascript:void(0)" @click="showHelp"><icon-help-circle></icon-help-circle></a>
</li>
</ul>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
// import juice from 'juice';
import store from '../store';
import DropdownMenu from './common/DropdownMenu';
import publishSvc from '../services/publishSvc';
import giteeGistProvider from '../services/providers/giteeGistProvider';
import gistProvider from '../services/providers/gistProvider';
export default {
components: {
DropdownMenu,
},
data: () => ({
allThemes: [{
name: '默认主题',
value: 'default',
}, {
name: '凝夜紫',
value: 'ningyezi',
}, {
name: '草原绿',
value: 'caoyuangreen',
}, {
name: '雁栖湖',
value: 'yanqihu',
}, {
name: '灵动蓝',
value: 'activeblue',
}, {
name: '极客黑',
value: 'jikebrack',
}, {
name: '极简黑',
value: 'simplebrack',
}, {
name: '全栈蓝',
value: 'allblue',
}, {
name: '自定义',
value: 'custom',
}],
baseCss: '',
sharing: false,
}),
computed: {
...mapGetters('theme', [
'currPreviewTheme',
'customPreviewThemeStyle',
]),
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
selectedTheme() {
return {
value: this.currPreviewTheme || 'default',
};
},
},
methods: {
...mapActions('data', [
'toggleSideBar',
]),
async changeTheme(item) {
await store.dispatch('theme/setPreviewTheme', item.value);
//
if (item.value === 'custom' && !this.customPreviewThemeStyle) {
this.toggleSideBar(true);
store.dispatch('data/setSideBarPanel', 'previewTheme');
}
},
showHelp() {
this.toggleSideBar(true);
store.dispatch('data/setSideBarPanel', 'help');
},
async share() {
if (this.sharing) {
store.dispatch('notification/info', '分享链接创建中...请稍后再试');
return;
}
try {
const currentFile = store.getters['file/current'];
await store.dispatch('modal/open', { type: 'shareHtmlPre', name: currentFile.name });
this.sharing = true;
const mainToken = store.getters['workspace/mainWorkspaceToken'];
if (!mainToken) {
store.dispatch('notification/info', '登录主文档空间之后才可使用分享功能!');
return;
}
let tempGistId = null;
const isGithub = mainToken.providerId === 'githubAppData';
const gistProviderId = isGithub ? 'gist' : 'giteegist';
const filterLocations = this.publishLocations.filter(it => it.providerId === gistProviderId
&& it.url && it.gistId);
if (filterLocations.length > 0) {
tempGistId = filterLocations[0].gistId;
}
const location = (isGithub ? gistProvider : giteeGistProvider).makeLocation(
mainToken,
`分享-${currentFile.name}`,
true,
null,
);
location.templateId = 'styledHtmlWithTheme';
location.fileId = currentFile.id;
location.gistId = tempGistId;
const { gistId } = await publishSvc.publishLocationAndStore(location);
const sharePage = mainToken.providerId === 'githubAppData' ? 'gistshare.html' : 'share.html';
const url = `${window.location.protocol}//${window.location.host}/${sharePage}?id=${gistId}`;
await store.dispatch('modal/open', { type: 'shareHtml', name: currentFile.name, url });
} catch (err) {
if (err) {
store.dispatch('notification/error', err);
}
} finally {
this.sharing = false;
}
},
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.preview-in-page-buttons {
position: absolute;
bottom: 10px;
right: -98px;
height: 34px;
padding: 5px;
background-color: rgba(84, 96, 114, 0.4);
border-radius: $border-radius-base;
transition: 0.5s;
display: flex;
.dropdown-menu {
display: none;
}
&:active,
&:focus,
&:hover {
right: 0;
transition: 0.5s;
background-color: #546072;
.dropdown-menu {
display: block;
}
}
.dropdown-menu-items {
bottom: 100%;
top: unset;
}
ul {
padding: 0;
margin-left: 10px;
line-height: 20px;
li {
line-height: 16px;
width: 16px;
display: inline-block;
vertical-align: middle;
list-style: none;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
.icon {
color: #fff;
opacity: 0.7;
&:active,
&:focus,
&:hover {
opacity: 1;
}
}
}
.before {
margin-left: -16px;
margin-right: 0;
}
}
}
</style>

191
src/components/SideBar.vue Normal file
View File

@ -0,0 +1,191 @@
<template>
<div class="side-bar flex flex--column">
<div class="side-title flex flex--row">
<button v-if="panel !== 'menu'" class="side-title__button button" @click="setPanel('menu')" v-title="'主菜单'">
<icon-dots-horizontal></icon-dots-horizontal>
</button>
<div class="side-title__title">
{{panelName}}
</div>
<button class="side-title__button button" @click="toggleSideBar(false)" v-title="'关闭侧边栏'">
<icon-close></icon-close>
</button>
</div>
<div class="side-bar__inner">
<main-menu v-if="panel === 'menu'"></main-menu>
<workspaces-menu v-else-if="panel === 'workspaces'"></workspaces-menu>
<sync-menu v-else-if="panel === 'sync'"></sync-menu>
<publish-menu v-else-if="panel === 'publish'"></publish-menu>
<history-menu v-else-if="panel === 'history'"></history-menu>
<export-menu v-else-if="panel === 'export'"></export-menu>
<import-export-menu v-else-if="panel === 'importExport'"></import-export-menu>
<workspace-backup-menu v-else-if="panel === 'workspaceBackups'"></workspace-backup-menu>
<div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help">
<pre class="markdown-highlighting" v-html="markdownSample"></pre>
</div>
<edit-theme-menu v-else-if="panel === 'editTheme'"></edit-theme-menu>
<preview-theme-menu v-else-if="panel === 'previewTheme'"></preview-theme-menu>
<div class="side-bar__panel side-bar__panel--toc" :class="{'side-bar__panel--hidden': panel !== 'toc'}">
<toc>
</toc>
</div>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import Toc from './Toc';
import MainMenu from './menus/MainMenu';
import WorkspacesMenu from './menus/WorkspacesMenu';
import SyncMenu from './menus/SyncMenu';
import PublishMenu from './menus/PublishMenu';
import HistoryMenu from './menus/HistoryMenu';
import ImportExportMenu from './menus/ImportExportMenu';
import WorkspaceBackupMenu from './menus/WorkspaceBackupMenu';
import EditThemeMenu from './menus/EditThemeMenu';
import PreviewThemeMenu from './menus/PreviewThemeMenu';
import markdownSample from '../data/markdownSample.md';
import markdownConversionSvc from '../services/markdownConversionSvc';
import store from '../store';
const panelNames = {
menu: '菜单',
workspaces: '文档空间',
help: 'Markdown 帮助',
toc: '目录',
sync: '同步',
publish: '发布',
history: '文件历史',
importExport: '导入/导出',
workspaceBackups: '文档空间备份',
editTheme: '编辑区主题',
previewTheme: '预览区主题',
};
export default {
components: {
Toc,
MainMenu,
WorkspacesMenu,
SyncMenu,
PublishMenu,
HistoryMenu,
ImportExportMenu,
WorkspaceBackupMenu,
EditThemeMenu,
PreviewThemeMenu,
},
data: () => ({
markdownSample: markdownConversionSvc.highlight(markdownSample),
}),
computed: {
panel() {
if (store.state.light) {
return null; // No menu in light mode
}
const result = store.getters['data/layoutSettings'].sideBarPanel;
return panelNames[result] ? result : 'menu';
},
panelName() {
return panelNames[this.panel];
},
},
methods: {
...mapActions('data', [
'toggleSideBar',
]),
...mapActions('data', {
setPanel: 'setSideBarPanel',
}),
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.side-bar {
overflow: hidden;
height: 100%;
hr {
margin: 10px 40px;
display: none;
border-top: 1px solid $hr-color;
}
* + hr {
display: block;
}
hr + hr {
display: none;
}
.textfield {
font-size: 14px;
height: 26px;
}
}
.side-bar__inner {
position: relative;
height: 100%;
}
.side-bar__panel {
position: absolute;
width: 100%;
height: 100%;
overflow: auto;
&::after {
content: '';
display: block;
height: 40px;
}
}
.side-bar__panel--hidden {
left: 1000px;
}
.side-bar__panel--menu {
padding: 10px;
}
.side-bar__panel--help {
padding: 0 10px 0 20px;
pre {
font-size: 0.9em;
font-variant-ligatures: no-common-ligatures;
line-height: 1.25;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
}
.code,
.img,
.imgref,
.cl-toc {
background-color: rgba(0, 0, 0, 0.05);
}
}
.side-bar__info {
padding: 10px;
margin: -10px -10px 10px;
background-color: $info-bg;
font-size: 0.95em;
p {
margin: 10px 15px;
font-size: 0.9rem;
opacity: 0.67;
line-height: 1.3;
}
}
</style>

View File

@ -0,0 +1,22 @@
<template>
<div class="splash-screen">
<div class="splash-screen__inner logo-background"></div>
</div>
</template>
<style lang="scss">
.splash-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 25px;
}
.splash-screen__inner {
margin: 0 auto;
max-width: 600px;
height: 100%;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<div class="stat-panel panel no-overflow">
<div class="stat-panel__block stat-panel__block--left" v-if="styles.showEditor">
<span class="stat-panel__block-name">
Markdown
<span v-if="textSelection">selection</span>
</span>
<span v-for="stat in textStats" :key="stat.id">
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
</span>
<span class="stat-panel__value"> {{line}} , {{column}} </span>
</div>
<div class="stat-panel__block stat-panel__block--right">
<span class="stat-panel__block-name">
HTML
<span v-if="htmlSelection">selection</span>
</span>
<span v-for="stat in htmlStats" :key="stat.id">
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import editorSvc from '../services/editorSvc';
import utils from '../services/utils';
class Stat {
constructor(name, regex) {
this.id = utils.uid();
this.name = name;
this.regex = new RegExp(regex, 'gm');
this.value = null;
}
}
export default {
data: () => ({
textSelection: false,
htmlSelection: false,
line: 0,
column: 0,
textStats: [
new Stat('字符', '[\\s\\S]'),
new Stat('字数', '\\S'),
new Stat('行数', '\n'),
],
htmlStats: [
new Stat('字数', '\\S'),
new Stat('段落', '\\S.*'),
],
}),
computed: mapGetters('layout', [
'styles',
]),
created() {
editorSvc.$on('sectionList', () => this.computeText());
editorSvc.$on('selectionRange', () => this.computeText());
editorSvc.$on('previewCtx', () => this.computeHtml());
editorSvc.$on('previewSelectionRange', () => this.computeHtml());
},
methods: {
computeText() {
setTimeout(() => {
this.textSelection = false;
let text = editorSvc.clEditor.getContent();
const beforeText = text.slice(0, editorSvc.clEditor.selectionMgr.selectionEnd);
const beforeLines = beforeText.split('\n');
this.line = beforeLines.length;
this.column = beforeLines.pop().length;
const selectedText = editorSvc.clEditor.selectionMgr.getSelectedText();
if (selectedText) {
this.textSelection = true;
text = selectedText;
}
this.textStats.forEach((stat) => {
stat.value = (text.match(stat.regex) || []).length;
});
}, 10);
},
computeHtml() {
setTimeout(() => {
let text;
if (editorSvc.previewSelectionRange) {
text = `${editorSvc.previewSelectionRange}`;
}
this.htmlSelection = true;
if (!text) {
this.htmlSelection = false;
({ text } = editorSvc.previewCtx);
}
if (text != null) {
this.htmlStats.forEach((stat) => {
stat.value = (text.match(stat.regex) || []).length;
});
}
}, 10);
},
},
};
</script>
<style lang="scss">
.stat-panel {
position: absolute;
width: 100%;
height: 100%;
color: #fff;
font-size: 12px;
}
.stat-panel__block {
margin: 0 10px;
}
.stat-panel__block--left {
float: left;
}
.stat-panel__block--right {
float: right;
}
.stat-panel__value {
font-weight: 600;
margin-left: 5px;
}
</style>

162
src/components/Toc.vue Normal file
View File

@ -0,0 +1,162 @@
<template>
<div class="toc">
<div class="toc__mask" :style="{top: (maskY - 5) + 'px'}"></div>
<div class="toc__inner"></div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import editorSvc from '../services/editorSvc';
export default {
data: () => ({
maskY: 0,
}),
computed: {
...mapGetters('layout', [
'styles',
]),
},
mounted() {
const tocElt = this.$el.querySelector('.toc__inner');
// TOC click behaviour
let isMousedown;
function onClick(e) {
if (!isMousedown) {
return;
}
e.preventDefault();
const y = e.clientY - tocElt.getBoundingClientRect().top;
editorSvc.previewCtx.sectionDescList.some((sectionDesc) => {
if (y >= sectionDesc.tocDimension.endOffset) {
return false;
}
const posInSection = (y - sectionDesc.tocDimension.startOffset)
/ (sectionDesc.tocDimension.height || 1);
const editorScrollTop = sectionDesc.editorDimension.startOffset
+ (sectionDesc.editorDimension.height * posInSection);
editorSvc.editorElt.parentNode.scrollTop = editorScrollTop;
const previewScrollTop = sectionDesc.previewDimension.startOffset
+ (sectionDesc.previewDimension.height * posInSection);
editorSvc.previewElt.parentNode.scrollTop = previewScrollTop;
return true;
});
}
tocElt.addEventListener('mouseup', () => {
isMousedown = false;
});
tocElt.addEventListener('mouseleave', () => {
isMousedown = false;
});
tocElt.addEventListener('mousedown', (e) => {
isMousedown = e.which === 1;
onClick(e);
});
tocElt.addEventListener('mousemove', (e) => {
onClick(e);
});
// Change mask postion on scroll
const updateMaskY = () => {
const scrollPosition = editorSvc.getScrollPosition();
if (scrollPosition) {
const sectionDesc = editorSvc.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];
this.maskY = sectionDesc.tocDimension.startOffset +
(scrollPosition.posInSection * sectionDesc.tocDimension.height);
}
};
this.$nextTick(() => {
editorSvc.editorElt.parentNode.addEventListener('scroll', () => {
if (this.styles.showEditor) {
updateMaskY();
}
});
editorSvc.previewElt.parentNode.addEventListener('scroll', () => {
if (!this.styles.showEditor) {
updateMaskY();
}
});
});
},
};
</script>
<style lang="scss">
.toc__inner {
position: relative;
color: rgba(0, 0, 0, 0.67);
cursor: pointer;
font-size: 9px;
padding: 10px 20px 40px;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.app--dark & {
color: rgba(255, 255, 255, 0.67);
}
* {
font-weight: inherit;
pointer-events: none;
}
.cl-toc-section {
h1,
h2 {
&::after {
display: none;
}
}
h1 {
margin: 1rem 0;
}
h2 {
margin: 0.5rem 0;
margin-left: 8px;
}
h3 {
margin: 0.33rem 0;
margin-left: 16px;
}
h4 {
margin: 0.22rem 0;
margin-left: 24px;
}
h5 {
margin: 0.11rem 0;
margin-left: 32px;
}
h6 {
margin: 0;
margin-left: 40px;
}
}
}
.toc__mask {
position: absolute;
left: 0;
width: 100%;
height: 35px;
background-color: rgba(255, 255, 255, 0.2);
pointer-events: none;
.app--dark & {
color: rgba(0, 0, 0, 0.2);
}
}
</style>

254
src/components/Tour.vue Normal file
View File

@ -0,0 +1,254 @@
<template>
<div class="tour" @keydown.esc.stop="skip">
<div class="tour-step" :class="'tour-step--' + step" :style="stepStyle">
<div class="tour-step__inner" v-if="step === 'welcome'">
<h2>欢迎回来</h2>
<p>新的<b>StackEdit中文版</b>在这里</p>
<p>请单击<b>下一步</b>快速浏览</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">跳过</button>
<button class="button button--resolve" @click="next">下一步</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'editor'">
<h2>您的Markdown编辑器</h2>
<p>StackEdit中文版实时将Markdown转换为HTML</p>
<p>点击 <icon-side-preview></icon-side-preview> 切换侧边预览</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">跳过</button>
<button class="button button--resolve" @click="next">下一步</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'explorer'">
<h2>文件资源管理器</h2>
<p>StackEdit中文版可以管理文档空间中的多个文件和文件夹</p>
<p>点击 <icon-folder></icon-folder> 打开文件资源管理器</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">跳过</button>
<button class="button button--resolve" @click="next">下一步</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'menu'">
<h2>切换侧边栏</h2>
<p>StackEdit中文版还可以同步和发布文件管理协作文档空间...</p>
<p>点击 <icon-provider provider-id="stackedit"></icon-provider> 浏览菜单</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">跳过</button>
<button class="button button--resolve" @click="next">下一步</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'theme'">
<h2>切换主题</h2>
<p>StackEdit中文版可以切换亮/暗主题</p>
<p>点击 <icon-switch-theme></icon-switch-theme> 切换主题</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">跳过</button>
<button class="button button--resolve" @click="next">下一步</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'end'">
<h2>Enjoy!</h2>
<p>如果您喜欢StackEdit中文版请在<a href="https://gitee.com/mafgwo/stackedit">Gitee仓库</a>上点一下Star谢谢</p>
<div class="tour-step__button-bar">
<button class="button button--resolve" @click="finish">确认</button>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import store from '../store';
const steps = [
'welcome',
'editor',
'explorer',
'menu',
'theme',
'end',
];
export default {
data: () => ({
stepIdx: 0,
stepStyles: {},
}),
computed: {
step() {
return steps[this.stepIdx];
},
stepStyle() {
return this.stepStyles[this.step] || {};
},
},
methods: {
updatePositions() {
document.querySelectorAll('[tour-step-anchor]').cl_each((anchorElt) => {
const anchorRect = anchorElt.getBoundingClientRect();
const anchorSteps = (anchorElt.getAttribute('tour-step-anchor') || '').split(',');
anchorSteps.forEach((step) => {
const style = {
top: `${anchorRect.top + (anchorRect.height / 2)}px`,
left: `${anchorRect.left + (anchorRect.width / 2)}px`,
};
switch (step) {
case 'welcome':
case 'end': {
style.top = `${anchorRect.top}px`;
break;
}
case 'editor':
case 'menu':
case 'theme': {
style.left = `${anchorRect.left}px`;
break;
}
case 'explorer': {
style.left = `${anchorRect.left + anchorRect.width}px`;
break;
}
default:
return;
}
Vue.set(this.stepStyles, step, style);
});
});
},
finish() {
store.dispatch('data/patchLayoutSettings', {
welcomeTourFinished: true,
});
},
next() {
this.stepIdx += 1;
},
},
mounted() {
this.$watch(
() => store.getters['layout/styles'],
() => this.updatePositions(),
{ immediate: true },
);
},
};
</script>
<style lang="scss">
@import '../styles/variables.scss';
.tour {
position: absolute;
top: 0;
left: 0;
}
.tour-step {
position: absolute;
}
$tour-step-background: transparentize(mix(#f3f3f3, $selection-highlighting-color, 75%), 0.025);
$tour-step-darkbackground: transparentize(mix(#4d4d4d, $selection-highlighting-color, 75%), 0.025);
$tour-step-width: 240px;
.tour-step__inner {
position: absolute;
background-color: $tour-step-background;
padding: 1.5em;
font-size: 0.9em;
line-height: 1.33;
width: $tour-step-width;
text-align: center;
border-radius: $border-radius-base;
.app--dark & {
background-color: $tour-step-darkbackground;
}
h2 {
margin: 0;
&::after {
display: none;
}
}
.icon,
.icon-provider {
width: 1.25em;
height: 1.25em;
vertical-align: bottom;
display: inline-block;
}
&::before {
content: '';
position: absolute;
}
.tour-step--welcome &,
.tour-step--end & {
left: -$tour-step-width/2;
top: 36px;
border-bottom-right-radius: 0;
&::before {
bottom: -10px;
right: 0;
border-top: 10px solid $tour-step-background;
border-left: 10px solid transparent;
.app--dark & {
border-top: 10px solid $tour-step-darkbackground;
}
}
}
.tour-step--editor &,
.tour-step--menu &,
.tour-step--theme & {
right: 15px;
border-top-right-radius: 0;
&::before {
top: 0;
right: -10px;
border-top: 10px solid $tour-step-background;
border-right: 10px solid transparent;
.app--dark & {
border-top: 10px solid $tour-step-darkbackground;
}
}
}
.tour-step--explorer & {
left: 15px;
border-top-left-radius: 0;
&::before {
top: 0;
left: -10px;
border-top: 10px solid $tour-step-background;
border-left: 10px solid transparent;
.app--dark & {
border-top: 10px solid $tour-step-darkbackground;
}
}
}
}
.tour-step__button-bar {
margin-top: 1.5em;
display: flex;
flex-direction: row;
justify-content: flex-end;
.button {
font-size: 1.1em;
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="user-image" :style="{backgroundImage: url}">
</div>
</template>
<script>
import userSvc from '../services/userSvc';
import store from '../store';
export default {
props: ['userId'],
computed: {
sanitizedUserId() {
return userSvc.sanitizeUserId(this.userId);
},
url() {
const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];
return userInfo && userInfo.imageUrl && `url('${userInfo.imageUrl}')`;
},
},
watch: {
sanitizedUserId: {
handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),
immediate: true,
},
},
};
</script>
<style lang="scss">
.user-image {
width: 100%;
height: 100%;
background-color: #fff;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<span class="user-name">{{name}}</span>
</template>
<script>
import userSvc from '../services/userSvc';
import store from '../store';
export default {
props: ['userId'],
computed: {
sanitizedUserId() {
return userSvc.sanitizeUserId(this.userId);
},
name() {
const userInfo = store.state.userInfo.itemsById[this.sanitizedUserId];
return userInfo ? userInfo.name : 'Someone';
},
},
watch: {
sanitizedUserId: {
handler: sanitizedUserId => userSvc.addUserId(sanitizedUserId),
immediate: true,
},
},
};
</script>

Some files were not shown because too many files have changed in this diff Show More