Added favicons.

Added sponsorship options.
Added pdf and pandoc export.
Added emoji and mermaid extensions.
Added find/replace support.
Added HTML template with TOC.
Updated welcome file.
This commit is contained in:
benweet 2017-11-04 16:59:48 +00:00
parent 43af45a6ed
commit abd0890512
102 changed files with 4076 additions and 874 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
.git
dist
.history

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM benweet/stackedit-base
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY package.json /usr/src/app/
COPY yarn.lock /usr/src/app/
COPY gulpfile.js /usr/src/app/
RUN yarn && yarn cache clean
COPY . /usr/src/app
ENV NODE_ENV production
RUN yarn run build
EXPOSE 8080
CMD [ "node", "." ]

View File

@ -4,6 +4,7 @@ var utils = require('./utils')
var config = require('../config') var config = require('../config')
var vueLoaderConfig = require('./vue-loader.conf') var vueLoaderConfig = require('./vue-loader.conf')
var StylelintPlugin = require('stylelint-webpack-plugin') var StylelintPlugin = require('stylelint-webpack-plugin')
var FaviconsWebpackPlugin = require('favicons-webpack-plugin')
function resolve (dir) { function resolve (dir) {
return path.join(__dirname, '..', dir) return path.join(__dirname, '..', dir)
@ -13,6 +14,10 @@ module.exports = {
entry: { entry: {
app: './src/' app: './src/'
}, },
node: {
// For mermaid
fs: 'empty' // jison generated code requires 'fs'
},
output: { output: {
path: config.build.assetsRoot, path: config.build.assetsRoot,
filename: '[name].js', filename: '[name].js',
@ -45,7 +50,14 @@ module.exports = {
{ {
test: /\.js$/, test: /\.js$/,
loader: 'babel-loader', loader: 'babel-loader',
include: [resolve('src'), resolve('test')] include: [resolve('src'), resolve('test'), resolve('node_modules/mermaid/src')],
exclude: [
resolve('node_modules/mermaid/src/diagrams/classDiagram/parser'),
resolve('node_modules/mermaid/src/diagrams/flowchart/parser'),
resolve('node_modules/mermaid/src/diagrams/gantt/parser'),
resolve('node_modules/mermaid/src/diagrams/gitGraph/parser'),
resolve('node_modules/mermaid/src/diagrams/sequenceDiagram/parser'),
],
}, },
{ {
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
@ -56,13 +68,9 @@ module.exports = {
} }
}, },
{ {
test: /\.(ttf|eot|otf|woff2)$/, loader: 'ignore-loader' test: /\.(ttf|eot|otf|woff2?)(\?.*)?$/,
}, loader: 'file-loader',
{
test: /\.woff(\?.*)?$/,
loader: 'url-loader',
options: { options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]') name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
} }
}, },
@ -76,6 +84,10 @@ module.exports = {
new StylelintPlugin({ new StylelintPlugin({
files: ['**/*.vue', '**/*.scss'] files: ['**/*.vue', '**/*.scss']
}), }),
new FaviconsWebpackPlugin({
logo: resolve('src/assets/favicon.png'),
title: 'StackEdit',
}),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
VERSION: JSON.stringify(require('../package.json').version) VERSION: JSON.stringify(require('../package.json').version)
}) })

View File

@ -96,7 +96,7 @@ var webpackConfig = merge(baseWebpackConfig, {
ServiceWorker: { ServiceWorker: {
events: true events: true
}, },
excludes: ['**/.*', '**/*.map', '**/index.html', '**/static/oauth2/callback.html'], excludes: ['**/.*', '**/*.map', '**/index.html', '**/static/oauth2/callback.html', '**/icons-*/*.png', '**/static/fonts/KaTeX_*'],
externals: ['/app', '/oauth2/callback'] externals: ['/app', '/oauth2/callback']
}) })
] ]

View File

@ -0,0 +1,57 @@
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 FaviconsWebpackPlugin = require('favicons-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/components/style.scss'
},
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
}
}),
]
}

View File

@ -4,11 +4,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>StackEdit</title> <title>StackEdit</title>
<link rel="canonical" href="https://stackedit.io/app"> <link rel="canonical" href="https://stackedit.io/app">
<!-- <link rel="icon" href="res-min/img/stackedit-32.ico" type="image/x-icon"> -->
<!-- <link rel="icon" sizes="192x192" href="res-min/img/logo-highres.png"> -->
<!-- <link rel="shortcut icon" href="res-min/img/stackedit-32.ico" type="image/x-icon"> -->
<!-- <link rel="shortcut icon" sizes="192x192" href="res-min/img/logo-highres.png"> -->
<!-- <link rel="apple-touch-icon-precomposed" sizes="152x152" href="res-min/img/logo-ipad-retina.png"> -->
<meta name="description" content="Free, open-source, full-featured Markdown editor."> <meta name="description" content="Free, open-source, full-featured Markdown editor.">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"> <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="mobile-web-app-capable" content="yes">
@ -18,5 +13,6 @@
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<script src="//cdn.monetizejs.com/api/js/latest/monetize.min.js"></script>
</body> </body>
</html> </html>

View File

@ -11,7 +11,8 @@
"scripts": { "scripts": {
"postinstall": "gulp build-prism", "postinstall": "gulp build-prism",
"start": "node build/dev-server.js", "start": "node build/dev-server.js",
"build": "node build/build.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", "lint": "eslint --ext .js,.vue src server",
"preversion": "npm run lint", "preversion": "npm run lint",
"postversion": "git push origin master --tags && npm publish", "postversion": "git push origin master --tags && npm publish",
@ -20,11 +21,14 @@
"major": "npm version major -m \"Tag v%s\"" "major": "npm version major -m \"Tag v%s\""
}, },
"dependencies": { "dependencies": {
"aws-sdk": "^2.133.0",
"bezier-easing": "^1.1.0", "bezier-easing": "^1.1.0",
"body-parser": "^1.18.2",
"clipboard": "^1.7.1", "clipboard": "^1.7.1",
"compression": "^1.7.0", "compression": "^1.7.0",
"diff-match-patch": "^1.0.0", "diff-match-patch": "^1.0.0",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"google-id-token-verifier": "^0.2.3",
"handlebars": "^4.0.10", "handlebars": "^4.0.10",
"indexeddbshim": "^3.0.4", "indexeddbshim": "^3.0.4",
"js-yaml": "^3.9.1", "js-yaml": "^3.9.1",
@ -37,12 +41,13 @@
"markdown-it-pandoc-renderer": "1.1.3", "markdown-it-pandoc-renderer": "1.1.3",
"markdown-it-sub": "^1.0.0", "markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0", "markdown-it-sup": "^1.0.0",
"mermaid": "^7.1.0",
"mousetrap": "^1.6.1", "mousetrap": "^1.6.1",
"normalize-scss": "^7.0.0", "normalize-scss": "^7.0.0",
"prismjs": "^1.6.0", "prismjs": "^1.6.0",
"raw-loader": "^0.5.1",
"request": "^2.82.0", "request": "^2.82.0",
"serve-static": "^1.12.6", "serve-static": "^1.12.6",
"tmp": "^0.0.33",
"vue": "^2.3.3", "vue": "^2.3.3",
"vuex": "^2.3.1" "vuex": "^2.3.1"
}, },
@ -59,7 +64,7 @@
"chalk": "^1.1.3", "chalk": "^1.1.3",
"connect-history-api-fallback": "^1.3.0", "connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.0.1", "copy-webpack-plugin": "^4.0.1",
"css-loader": "^0.28.4", "css-loader": "^0.28.7",
"eslint": "^3.19.0", "eslint": "^3.19.0",
"eslint-config-airbnb-base": "^11.1.3", "eslint-config-airbnb-base": "^11.1.3",
"eslint-friendly-formatter": "^2.0.7", "eslint-friendly-formatter": "^2.0.7",
@ -70,6 +75,7 @@
"eventsource-polyfill": "^0.9.6", "eventsource-polyfill": "^0.9.6",
"express": "^4.15.5", "express": "^4.15.5",
"extract-text-webpack-plugin": "^2.0.0", "extract-text-webpack-plugin": "^2.0.0",
"favicons-webpack-plugin": "^0.0.7",
"file-loader": "^0.11.1", "file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.1.3", "friendly-errors-webpack-plugin": "^1.1.3",
"gulp": "^3.9.1", "gulp": "^3.9.1",
@ -83,6 +89,7 @@
"opn": "^4.0.2", "opn": "^4.0.2",
"optimize-css-assets-webpack-plugin": "^1.3.0", "optimize-css-assets-webpack-plugin": "^1.3.0",
"ora": "^1.2.0", "ora": "^1.2.0",
"raw-loader": "^0.5.1",
"rimraf": "^2.6.0", "rimraf": "^2.6.0",
"sass-loader": "^6.0.5", "sass-loader": "^6.0.5",
"semver": "^5.3.0", "semver": "^5.3.0",

View File

@ -1,7 +1,13 @@
const compression = require('compression'); const compression = require('compression');
const serveStatic = require('serve-static'); const serveStatic = require('serve-static');
const bodyParser = require('body-parser');
const path = require('path'); const path = require('path');
const user = require('./user');
const github = require('./github'); const github = require('./github');
const pdf = require('./pdf');
const pandoc = require('./pandoc');
const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);
module.exports = (app, serveV4) => { module.exports = (app, serveV4) => {
// Use gzip compression // Use gzip compression
@ -22,10 +28,20 @@ module.exports = (app, serveV4) => {
app.use(compression()); app.use(compression());
} }
// Parse body mostly for PayPal IPN
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
extended: false,
}));
app.get('/oauth2/githubToken', github.githubToken); app.get('/oauth2/githubToken', github.githubToken);
app.get('/userInfo', user.userInfo);
app.post('/paypalIpn', user.paypalIpn);
app.post('/pdfExport', pdf.generate);
app.post('/pandocExport', pandoc.generate);
if (serveV4) { if (serveV4) {
/* eslint-disable global-require, import/no-unresolved */ /* eslint-disable global-require, import/no-unresolved */
app.post('/pdfExport', require('../stackedit_v4/app/pdf').export);
app.post('/sshPublish', require('../stackedit_v4/app/ssh').publish); app.post('/sshPublish', require('../stackedit_v4/app/ssh').publish);
app.post('/picasaImportImg', require('../stackedit_v4/app/picasa').importImg); app.post('/picasaImportImg', require('../stackedit_v4/app/picasa').importImg);
app.get('/downloadImport', require('../stackedit_v4/app/download').importPublic); app.get('/downloadImport', require('../stackedit_v4/app/download').importPublic);
@ -33,29 +49,39 @@ module.exports = (app, serveV4) => {
} }
// Serve callback.html in /app // Serve callback.html in /app
app.get('/oauth2/callback', (req, res) => res.sendFile(path.join(__dirname, '../static/oauth2/callback.html'))); app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html')));
// Serve static resources // Serve static resources
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (serveV4) { if (serveV4) {
// Serve landing.html in / // Serve landing.html in /
app.get('/', (req, res) => res.sendFile(require.resolve('../stackedit_v4/views/landing.html'))); app.get('/', (req, res) => res.sendFile(resolvePath('stackedit_v4/views/landing.html')));
// Serve editor.html in /viewer // Serve editor.html in /viewer
app.get('/editor', (req, res) => res.sendFile(require.resolve('../stackedit_v4/views/editor.html'))); app.get('/editor', (req, res) => res.sendFile(resolvePath('stackedit_v4/views/editor.html')));
// Serve viewer.html in /viewer // Serve viewer.html in /viewer
app.get('/viewer', (req, res) => res.sendFile(require.resolve('../stackedit_v4/views/viewer.html'))); app.get('/viewer', (req, res) => res.sendFile(resolvePath('stackedit_v4/views/viewer.html')));
} }
// Serve index.html in /app // Serve index.html in /app
app.get('/app', (req, res) => res.sendFile(path.join(__dirname, '../dist/index.html'))); app.get('/app', (req, res) => res.sendFile(resolvePath('dist/index.html')));
app.use(serveStatic(path.join(__dirname, '../dist'))); // Serve style.css with 1 day max-age
app.get('/style.css', (req, res) => res.sendFile(resolvePath('dist/style.css'), {
maxAge: '1d',
}));
// Serve the static folder with 1 year max-age
app.use('/static', serveStatic(resolvePath('dist/static'), {
maxAge: '1y',
}));
app.use(serveStatic(resolvePath('dist')));
if (serveV4) { if (serveV4) {
app.use(serveStatic(path.dirname(require.resolve('../stackedit_v4/public/cache.manifest')))); app.use(serveStatic(path.dirname(resolvePath('stackedit_v4/public/cache.manifest'))));
// Error 404 // Error 404
app.use((req, res) => res.status(404).sendFile(require.resolve('../stackedit_v4/views/error_404.html'))); app.use((req, res) => res.status(404).sendFile(resolvePath('stackedit_v4/views/error_404.html')));
} }
} }
}; };

152
server/pandoc.js Normal file
View File

@ -0,0 +1,152 @@
/* global window */
const spawn = require('child_process').spawn;
const fs = require('fs');
const tmp = require('tmp');
const user = require('./user');
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';
Promise.all([
user.checkSponsor(req.query.idToken),
user.checkMonetize(req.query.token),
])
.then(([isSponsor, isMonetize]) => {
if (!isSponsor && !isMonetize) {
throw new Error('unauthorized');
}
return 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('--latex-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 (!isNaN(options.tocDepth)) {
params.push('--toc-depth', options.tocDepth);
}
options.highlightStyle = highlightStyles.indexOf(options.highlightStyle) !== -1 ? 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 binPath = process.env.PANDOC_PATH || 'pandoc';
const format = outputFormat === 'pdf' ? 'latex' : outputFormat;
params.push('-f', 'json', '-t', format, '-o', filePath);
const pandoc = spawn(binPath, 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) => {
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.');
}
});
};

188
server/pdf.js Normal file
View File

@ -0,0 +1,188 @@
/* global window,MathJax */
const spawn = require('child_process').spawn;
const fs = require('fs');
const tmp = require('tmp');
const user = require('./user');
/* 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 = '';
Promise.all([
user.checkSponsor(req.query.idToken),
user.checkMonetize(req.query.token),
])
.then(([isSponsor, isMonetize]) => {
if (!isSponsor && !isMonetize) {
throw new Error('unauthorized');
}
return 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', isNaN(marginTop) ? 25 : marginTop);
const marginRight = parseInt(`${options.marginRight}`, 10);
params.push('-R', isNaN(marginRight) ? 25 : marginRight);
const marginBottom = parseInt(`${options.marginBottom}`, 10);
params.push('-B', isNaN(marginBottom) ? 25 : marginBottom);
const marginLeft = parseInt(`${options.marginLeft}`, 10);
params.push('-L', 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.indexOf(options.pageSize) === -1 ? 'A4' : options.pageSize);
// Use a temp file as wkhtmltopdf can't access /dev/stdout on Amazon EC2 for some reason
const binPath = process.env.WKHTMLTOPDF_PATH || 'wkhtmltopdf';
params.push('--run-script', `${waitForJavaScript.toString()}waitForJavaScript()`);
params.push('--window-status', 'done');
const wkhtmltopdf = spawn(binPath, 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) => {
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.');
}
});
};

129
server/user.js Normal file
View File

@ -0,0 +1,129 @@
const request = require('request');
const AWS = require('aws-sdk');
const verifier = require('google-id-token-verifier');
const BUCKET_NAME = process.env.USER_BUCKET_NAME || 'stackedit-users';
const PAYPAL_URI = process.env.PAYPAL_URI || 'https://www.paypal.com/cgi-bin/webscr';
const PAYPAL_RECEIVER_EMAIL = process.env.PAYPAL_RECEIVER_EMAIL || 'stackedit.project@gmail.com';
const GOOGLE_CLIENT_ID = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const s3Client = new AWS.S3();
const cb = (resolve, reject) => (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
};
exports.getUser = id => new Promise((resolve, reject) => {
s3Client.getObject({
Bucket: BUCKET_NAME,
Key: id,
}, cb(resolve, reject));
})
.then(
res => JSON.parse(`${res.Body}`),
(err) => {
if (err.code !== 'NoSuchKey') {
throw err;
}
});
exports.putUser = (id, user) => new Promise((resolve, reject) => {
s3Client.putObject({
Bucket: BUCKET_NAME,
Key: id,
Body: JSON.stringify(user),
}, cb(resolve, reject));
});
exports.removeUser = id => new Promise((resolve, reject) => {
s3Client.deleteObject({
Bucket: BUCKET_NAME,
Key: id,
}, cb(resolve, reject));
});
exports.getUserFromToken = idToken => new Promise(
(resolve, reject) => verifier.verify(idToken, GOOGLE_CLIENT_ID, cb(resolve, reject)))
.then(tokenInfo => exports.getUser(tokenInfo.sub));
exports.userInfo = (req, res) => exports.getUserFromToken(req.query.idToken)
.then(user => res.send(Object.assign({
sponsorUntil: 0,
}, user)),
err => res.status(400).send(err ? err.message || err.toString() : 'invalid_token'));
exports.paypalIpn = (req, res, next) => Promise.resolve()
.then(() => {
const userId = req.body.custom;
const paypalEmail = req.body.payer_email;
const gross = parseFloat(req.body.mc_gross);
let sponsorUntil;
if (gross === 5) {
sponsorUntil = Date.now() + (3 * 31 * 24 * 60 * 60 * 1000); // 3 months
} else if (gross === 15) {
sponsorUntil = Date.now() + (366 * 24 * 60 * 60 * 1000); // 1 year
} else if (gross === 25) {
sponsorUntil = Date.now() + (2 * 366 * 24 * 60 * 60 * 1000); // 2 years
} else if (gross === 50) {
sponsorUntil = Date.now() + (5 * 366 * 24 * 60 * 60 * 1000); // 5 years
}
if (
req.body.receiver_email !== PAYPAL_RECEIVER_EMAIL ||
req.body.payment_status !== 'Completed' ||
req.body.mc_currency !== 'USD' ||
(req.body.txn_type !== 'web_accept' && req.body.txn_type !== 'subscr_payment') ||
!userId || !sponsorUntil
) {
// Ignoring PayPal IPN
return res.end();
}
// Processing PayPal IPN
req.body.cmd = '_notify-validate';
return new Promise((resolve, reject) => request.post({
uri: PAYPAL_URI,
form: req.body,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (body !== 'VERIFIED') {
reject(new Error('PayPal IPN unverified'));
} else {
resolve();
}
}))
.then(() => exports.putUser(userId, {
paypalEmail,
sponsorUntil,
}))
.then(() => res.end());
})
.catch(next);
exports.checkSponsor = (idToken) => {
if (!idToken) {
return Promise.resolve(false);
}
return exports.getUserFromToken(idToken)
.then(userInfo => userInfo && userInfo.sponsorUntil > Date.now(), () => false);
};
exports.checkMonetize = (token) => {
if (!token) {
return Promise.resolve(false);
}
return new Promise(resolve => request({
uri: 'https://monetizejs.com/api/payments',
qs: {
access_token: token,
},
json: true,
}, (err, paymentsRes, payments) => {
const authorized = payments && payments.app === 'ESTHdCYOi18iLhhO' && (
(payments.chargeOption && payments.chargeOption.alias === 'once') ||
(payments.subscriptionOption && payments.subscriptionOption.alias === 'yearly'));
resolve(!err && paymentsRes.statusCode === 200 && authorized);
}));
};

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -1,26 +1,26 @@
<template> <template>
<div class="button-bar"> <div class="button-bar">
<div class="button-bar__inner button-bar__inner--top"> <div class="button-bar__inner button-bar__inner--top">
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.showNavigationBar }" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showNavigationBar }" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'">
<icon-navigation-bar></icon-navigation-bar> <icon-navigation-bar></icon-navigation-bar>
</div> </button>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.showSidePreview }" @click="toggleSidePreview()" v-title="'Toggle side preview'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showSidePreview }" @click="toggleSidePreview()" v-title="'Toggle side preview'">
<icon-side-preview></icon-side-preview> <icon-side-preview></icon-side-preview>
</div> </button>
<div class="button-bar__button" @click="toggleEditor(false)" v-title="'Reader mode'"> <button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'">
<icon-eye></icon-eye> <icon-eye></icon-eye>
</div> </button>
</div> </div>
<div class="button-bar__inner button-bar__inner--bottom"> <div class="button-bar__inner button-bar__inner--bottom">
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'">
<icon-target></icon-target> <icon-target></icon-target>
</div> </button>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
<icon-scroll-sync></icon-scroll-sync> <icon-scroll-sync></icon-scroll-sync>
</div> </button>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
<icon-status-bar></icon-status-bar> <icon-status-bar></icon-status-bar>
</div> </button>
</div> </div>
</div> </div>
</template> </template>
@ -60,19 +60,27 @@ export default {
} }
.button-bar__button { .button-bar__button {
cursor: pointer; display: block;
color: rgba(0, 0, 0, 0.2); color: rgba(0, 0, 0, 0.2);
width: 26px; width: 26px;
height: 26px; height: 26px;
padding: 2px; padding: 2px;
margin: 3px 0; margin: 3px 0;
&:active,
&:focus,
&:hover { &:hover {
color: rgba(0, 0, 0, 0.4); color: rgba(0, 0, 0, 0.2);
} }
} }
.button-bar__button--on { .button-bar__button--on {
color: rgba(0, 0, 0, 0.4); color: rgba(0, 0, 0, 0.4);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.4);
}
} }
</style> </style>

View File

@ -35,7 +35,6 @@ export default {
font-family: $font-family-monospace; font-family: $font-family-monospace;
font-size: $font-size-monospace; font-size: $font-size-monospace;
font-variant-ligatures: no-common-ligatures; font-variant-ligatures: no-common-ligatures;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
word-wrap: normal; word-wrap: normal;
height: auto; height: auto;

View File

@ -22,7 +22,7 @@ import { mapMutations, mapActions } from 'vuex';
import utils from '../services/utils'; import utils from '../services/utils';
export default { export default {
name: 'explorer-node', name: 'explorer-node', // Required for recursivity
props: ['node', 'depth'], props: ['node', 'depth'],
data: () => ({ data: () => ({
editingValue: '', editingValue: '',

View File

@ -0,0 +1,359 @@
<template>
<div class="find-replace" @keyup.esc="onEscape">
<button class="find-replace__close-button button not-tabbable" @click="close()" v-title="'Close'">
<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" @keyup.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')">Previous</button>
<button class="find-replace__button button" @click="find('forward')">Next</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" @keyup.enter="replace" v-model="replaceText">
</div>
<div class="find-replace__row flex flex--row flex--end">
<button class="find-replace__button button" @click="replace">Replace</button>
<button class="find-replace__button button" @click="replaceAll">All</button>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import editorEngineSvc from '../services/editorEngineSvc';
import cledit from '../libs/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 computedLocalSetting = key => ({
get() {
return store.getters['data/localSettings'][key];
},
set(value) {
store.dispatch('data/patchLocalSettings', {
[key]: value,
});
},
});
class DynamicClassApplier {
constructor(cssClass, offset, silent) {
this.startMarker = new cledit.Marker(offset.start);
this.endMarker = new cledit.Marker(offset.end);
editorEngineSvc.clEditor.addMarker(this.startMarker);
editorEngineSvc.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 = () => {
editorEngineSvc.clEditor.removeMarker(this.startMarker);
editorEngineSvc.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: computedLocalSetting('findCaseSensitive'),
findUseRegexp: computedLocalSetting('findUseRegexp'),
},
methods: {
highlightOccurrences() {
const oldClassAppliers = {};
Object.keys(this.classAppliers).forEach((key) => {
const classApplier = this.classAppliers[key];
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');
editorEngineSvc.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.keys(oldClassAppliers).forEach((key) => {
const classApplier = oldClassAppliers[key];
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.selectedClassApplier;
this.unselectClassApplier();
const selectionMgr = editorEngineSvc.clEditor.selectionMgr;
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;
}
editorEngineSvc.clEditor.replaceAll(
this.replaceRegex, this.replaceText, this.selectedClassApplier.startMarker.offset);
Vue.nextTick(() => this.find());
}
},
replaceAll() {
if (this.searchRegex) {
editorEngineSvc.clEditor.replaceAll(this.searchRegex, this.replaceText);
}
},
close() {
this.$store.commit('findReplace/setType');
},
onEscape() {
editorEngineSvc.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
editorEngineSvc.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
this.$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
editorEngineSvc.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 'common/variables.scss';
.find-replace {
padding: 0 35px 0 25px;
}
.find-replace__row {
margin: 10px 0;
}
.find-replace__button {
font-size: 16px;
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);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.25);
}
}
.find-replace__button--on {
color: rgba(0, 0, 0, 0.67);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 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;
color: rgba(0, 0, 0, 0.25);
width: 25px;
height: 25px;
padding: 2px;
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.33);
}
}
.find-replace__find-stats {
text-align: right;
font-size: 0.75em;
opacity: 0.5;
}
.find-replace-highlighting {
background-color: #ff0;
}
.find-replace-selection {
background-color: #ff9632;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="layout"> <div class="layout">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}"> <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: constants.explorerWidth + 'px' }"> <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> <explorer></explorer>
</div> </div>
<div class="layout__panel flex flex--column" :style="{ width: styles.innerWidth + 'px' }"> <div class="layout__panel flex flex--column" :style="{ width: styles.innerWidth + 'px' }">
@ -11,6 +11,9 @@
<div class="layout__panel flex flex--row" :style="{ height: styles.innerHeight + 'px' }"> <div class="layout__panel flex flex--row" :style="{ height: styles.innerHeight + 'px' }">
<div class="layout__panel layout__panel--editor" v-show="styles.showEditor" :style="{ width: styles.editorWidth + 'px', 'font-size': styles.fontSize + 'px' }"> <div class="layout__panel layout__panel--editor" v-show="styles.showEditor" :style="{ width: styles.editorWidth + 'px', 'font-size': styles.fontSize + 'px' }">
<editor></editor> <editor></editor>
<div v-if="showFindReplace" class="layout__panel layout__panel--find-replace">
<find-replace></find-replace>
</div>
</div> </div>
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{ width: constants.buttonBarWidth + 'px' }"> <div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{ width: constants.buttonBarWidth + 'px' }">
<button-bar></button-bar> <button-bar></button-bar>
@ -23,7 +26,7 @@
<status-bar></status-bar> <status-bar></status-bar>
</div> </div>
</div> </div>
<div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{ width: constants.sideBarWidth + 'px' }"> <div class="layout__panel layout__panel--side-bar" v-show="styles.showSideBar" :style="{ width: styles.layoutOverflow ? '100%' : constants.sideBarWidth + 'px' }">
<side-bar></side-bar> <side-bar></side-bar>
</div> </div>
</div> </div>
@ -39,7 +42,9 @@ import Explorer from './Explorer';
import SideBar from './SideBar'; import SideBar from './SideBar';
import Editor from './Editor'; import Editor from './Editor';
import Preview from './Preview'; import Preview from './Preview';
import FindReplace from './FindReplace';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
import editorEngineSvc from '../services/editorEngineSvc';
export default { export default {
components: { components: {
@ -50,12 +55,16 @@ export default {
SideBar, SideBar,
Editor, Editor,
Preview, Preview,
FindReplace,
}, },
computed: { computed: {
...mapGetters('layout', [ ...mapGetters('layout', [
'constants', 'constants',
'styles', 'styles',
]), ]),
showFindReplace() {
return !!this.$store.state.findReplace.type;
},
}, },
methods: { methods: {
...mapMutations('layout', [ ...mapMutations('layout', [
@ -75,6 +84,10 @@ export default {
const previewElt = this.$el.querySelector('.preview__inner-2'); const previewElt = this.$el.querySelector('.preview__inner-2');
const tocElt = this.$el.querySelector('.toc__inner'); const tocElt = this.$el.querySelector('.toc__inner');
editorSvc.init(editorElt, previewElt, tocElt); editorSvc.init(editorElt, previewElt, tocElt);
// Focus on the editor every time reader mode is disabled
this.$watch(() => this.styles.showEditor,
showEditor => showEditor && editorEngineSvc.clEditor.focus());
}, },
destroyed() { destroyed() {
window.removeEventListener('resize', this.updateStyle); window.removeEventListener('resize', this.updateStyle);
@ -122,4 +135,14 @@ export default {
.layout__panel--side-bar { .layout__panel--side-bar {
background-color: #dadada; background-color: #dadada;
} }
.layout__panel--find-replace {
background-color: #e6e6e6;
position: absolute;
left: 0;
bottom: 0;
width: 300px;
height: auto;
border-top-right-radius: $border-radius-base;
}
</style> </style>

View File

@ -5,18 +5,23 @@
<templates-modal v-else-if="config.type === 'templates'"></templates-modal> <templates-modal v-else-if="config.type === 'templates'"></templates-modal>
<about-modal v-else-if="config.type === 'about'"></about-modal> <about-modal v-else-if="config.type === 'about'"></about-modal>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal> <html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal>
<pdf-export-modal v-else-if="config.type === 'pdfExport'"></pdf-export-modal>
<pandoc-export-modal v-else-if="config.type === 'pandocExport'"></pandoc-export-modal>
<link-modal v-else-if="config.type === 'link'"></link-modal> <link-modal v-else-if="config.type === 'link'"></link-modal>
<image-modal v-else-if="config.type === 'image'"></image-modal> <image-modal v-else-if="config.type === 'image'"></image-modal>
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
<sync-management-modal v-else-if="config.type === 'syncManagement'"></sync-management-modal> <sync-management-modal v-else-if="config.type === 'syncManagement'"></sync-management-modal>
<publish-management-modal v-else-if="config.type === 'publishManagement'"></publish-management-modal> <publish-management-modal v-else-if="config.type === 'publishManagement'"></publish-management-modal>
<google-drive-sync-modal v-else-if="config.type === 'googleDriveSync'"></google-drive-sync-modal> <sponsor-modal v-else-if="config.type === 'sponsor'"></sponsor-modal>
<!-- Providers -->
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
<google-drive-save-modal v-else-if="config.type === 'googleDriveSave'"></google-drive-save-modal>
<google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal> <google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal>
<dropbox-account-modal v-else-if="config.type === 'dropboxAccount'"></dropbox-account-modal> <dropbox-account-modal v-else-if="config.type === 'dropboxAccount'"></dropbox-account-modal>
<dropbox-sync-modal v-else-if="config.type === 'dropboxSync'"></dropbox-sync-modal> <dropbox-save-modal v-else-if="config.type === 'dropboxSave'"></dropbox-save-modal>
<dropbox-publish-modal v-else-if="config.type === 'dropboxPublish'"></dropbox-publish-modal> <dropbox-publish-modal v-else-if="config.type === 'dropboxPublish'"></dropbox-publish-modal>
<github-account-modal v-else-if="config.type === 'githubAccount'"></github-account-modal> <github-account-modal v-else-if="config.type === 'githubAccount'"></github-account-modal>
<github-sync-modal v-else-if="config.type === 'githubSync'"></github-sync-modal> <github-open-modal v-else-if="config.type === 'githubOpen'"></github-open-modal>
<github-save-modal v-else-if="config.type === 'githubSave'"></github-save-modal>
<github-publish-modal v-else-if="config.type === 'githubPublish'"></github-publish-modal> <github-publish-modal v-else-if="config.type === 'githubPublish'"></github-publish-modal>
<gist-sync-modal v-else-if="config.type === 'gistSync'"></gist-sync-modal> <gist-sync-modal v-else-if="config.type === 'gistSync'"></gist-sync-modal>
<gist-publish-modal v-else-if="config.type === 'gistPublish'"></gist-publish-modal> <gist-publish-modal v-else-if="config.type === 'gistPublish'"></gist-publish-modal>
@ -25,70 +30,81 @@
<blogger-page-publish-modal v-else-if="config.type === 'bloggerPagePublish'"></blogger-page-publish-modal> <blogger-page-publish-modal v-else-if="config.type === 'bloggerPagePublish'"></blogger-page-publish-modal>
<zendesk-account-modal v-else-if="config.type === 'zendeskAccount'"></zendesk-account-modal> <zendesk-account-modal v-else-if="config.type === 'zendeskAccount'"></zendesk-account-modal>
<zendesk-publish-modal v-else-if="config.type === 'zendeskPublish'"></zendesk-publish-modal> <zendesk-publish-modal v-else-if="config.type === 'zendeskPublish'"></zendesk-publish-modal>
<div v-else class="modal__inner-1"> <modal-inner v-else aria-label="Dialog">
<div class="modal__inner-2"> <div class="modal__content" v-html="config.content"></div>
<div class="modal__content" v-html="config.content"></div> <div class="modal__button-bar">
<div class="modal__button-bar"> <button v-if="config.rejectText" class="button" @click="config.reject()">{{config.rejectText}}</button>
<button v-if="config.rejectText" class="button" @click="config.reject()">{{config.rejectText}}</button> <button v-if="config.resolveText" class="button" @click="config.resolve()">{{config.resolveText}}</button>
<button v-if="config.resolveText" class="button" @click="config.resolve()">{{config.resolveText}}</button>
</div>
</div> </div>
</div> </modal-inner>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import editorEngineSvc from '../services/editorEngineSvc'; import editorEngineSvc from '../services/editorEngineSvc';
import ModalInner from './modals/common/ModalInner';
import FilePropertiesModal from './modals/FilePropertiesModal'; import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal'; import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal'; import TemplatesModal from './modals/TemplatesModal';
import AboutModal from './modals/AboutModal'; import AboutModal from './modals/AboutModal';
import HtmlExportModal from './modals/HtmlExportModal'; import HtmlExportModal from './modals/HtmlExportModal';
import PdfExportModal from './modals/PdfExportModal';
import PandocExportModal from './modals/PandocExportModal';
import LinkModal from './modals/LinkModal'; import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal'; import ImageModal from './modals/ImageModal';
import GooglePhotoModal from './modals/GooglePhotoModal';
import SyncManagementModal from './modals/SyncManagementModal'; import SyncManagementModal from './modals/SyncManagementModal';
import PublishManagementModal from './modals/PublishManagementModal'; import PublishManagementModal from './modals/PublishManagementModal';
import GoogleDriveSyncModal from './modals/GoogleDriveSyncModal'; import SponsorModal from './modals/SponsorModal';
import GoogleDrivePublishModal from './modals/GoogleDrivePublishModal';
import DropboxAccountModal from './modals/DropboxAccountModal'; // Providers
import DropboxSyncModal from './modals/DropboxSyncModal'; import GooglePhotoModal from './modals/providers/GooglePhotoModal';
import DropboxPublishModal from './modals/DropboxPublishModal'; import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
import GithubAccountModal from './modals/GithubAccountModal'; import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
import GithubSyncModal from './modals/GithubSyncModal'; import DropboxAccountModal from './modals/providers/DropboxAccountModal';
import GithubPublishModal from './modals/GithubPublishModal'; import DropboxSaveModal from './modals/providers/DropboxSaveModal';
import GistSyncModal from './modals/GistSyncModal'; import DropboxPublishModal from './modals/providers/DropboxPublishModal';
import GistPublishModal from './modals/GistPublishModal'; import GithubAccountModal from './modals/providers/GithubAccountModal';
import WordpressPublishModal from './modals/WordpressPublishModal'; import GithubOpenModal from './modals/providers/GithubOpenModal';
import BloggerPublishModal from './modals/BloggerPublishModal'; import GithubSaveModal from './modals/providers/GithubSaveModal';
import BloggerPagePublishModal from './modals/BloggerPagePublishModal'; import GithubPublishModal from './modals/providers/GithubPublishModal';
import ZendeskAccountModal from './modals/ZendeskAccountModal'; import GistSyncModal from './modals/providers/GistSyncModal';
import ZendeskPublishModal from './modals/ZendeskPublishModal'; import GistPublishModal from './modals/providers/GistPublishModal';
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';
const getTabbables = container => container.querySelectorAll('a[href], button, .textfield') const getTabbables = container => container.querySelectorAll('a[href], button, .textfield')
// Filter enabled and visible element // Filter enabled and visible element
.cl_filter(el => !el.disabled && el.offsetParent !== null); .cl_filter(el => !el.disabled && el.offsetParent !== null && !el.classList.contains('not-tabbable'));
export default { export default {
components: { components: {
ModalInner,
FilePropertiesModal, FilePropertiesModal,
SettingsModal, SettingsModal,
TemplatesModal, TemplatesModal,
AboutModal, AboutModal,
HtmlExportModal, HtmlExportModal,
PdfExportModal,
PandocExportModal,
LinkModal, LinkModal,
ImageModal, ImageModal,
GooglePhotoModal,
SyncManagementModal, SyncManagementModal,
PublishManagementModal, PublishManagementModal,
GoogleDriveSyncModal, SponsorModal,
// Providers
GooglePhotoModal,
GoogleDriveSaveModal,
GoogleDrivePublishModal, GoogleDrivePublishModal,
DropboxAccountModal, DropboxAccountModal,
DropboxSyncModal, DropboxSaveModal,
DropboxPublishModal, DropboxPublishModal,
GithubAccountModal, GithubAccountModal,
GithubSyncModal, GithubOpenModal,
GithubSaveModal,
GithubPublishModal, GithubPublishModal,
GistSyncModal, GistSyncModal,
GistPublishModal, GistPublishModal,
@ -160,7 +176,7 @@ export default {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(128, 128, 128, 0.5); background-color: rgba(160, 160, 160, 0.5);
overflow: auto; overflow: auto;
} }
@ -172,7 +188,7 @@ export default {
} }
.modal__inner-2 { .modal__inner-2 {
margin: 50px 10px 100px; margin: 40px 10px 100px;
background-color: #fff; background-color: #fff;
padding: 40px 50px 30px; padding: 40px 50px 30px;
border-radius: $border-radius-base; border-radius: $border-radius-base;
@ -200,7 +216,8 @@ export default {
} }
} }
.modal__content :first-child { .modal__content > :first-child,
.modal__content > .modal__image:first-child + * {
margin-top: 0; margin-top: 0;
} }
@ -309,7 +326,7 @@ export default {
.tabs { .tabs {
border-bottom: 1px solid $hr-color; border-bottom: 1px solid $hr-color;
margin-bottom: 2em; margin: 1em 0 2em;
&::after { &::after {
content: ''; content: '';

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor}"> <nav class="navigation-bar" :class="{'navigation-bar--editor': styles.showEditor}">
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button"> <div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button button" @click="toggleExplorer()" v-title="'Toggle explorer'"> <button class="navigation-bar__button button" @click="toggleExplorer()" v-title="'Toggle explorer'">
<icon-folder></icon-folder> <icon-folder></icon-folder>
@ -78,7 +78,7 @@
<icon-format-horizontal-rule></icon-format-horizontal-rule> <icon-format-horizontal-rule></icon-format-horizontal-rule>
</button> </button>
</div> </div>
</div> </nav>
</template> </template>
<script> <script>

View File

@ -31,7 +31,6 @@
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import Toc from './Toc'; import Toc from './Toc';
import MenuEntry from './menus/MenuEntry';
import MainMenu from './menus/MainMenu'; import MainMenu from './menus/MainMenu';
import SyncMenu from './menus/SyncMenu'; import SyncMenu from './menus/SyncMenu';
import PublishMenu from './menus/PublishMenu'; import PublishMenu from './menus/PublishMenu';
@ -53,7 +52,6 @@ const panelNames = {
export default { export default {
components: { components: {
Toc, Toc,
MenuEntry,
MainMenu, MainMenu,
SyncMenu, SyncMenu,
PublishMenu, PublishMenu,

View File

@ -3,7 +3,7 @@
<div class="stat-panel__block stat-panel__block--left" v-if="styles.showEditor"> <div class="stat-panel__block stat-panel__block--left" v-if="styles.showEditor">
<span class="stat-panel__block-name"> <span class="stat-panel__block-name">
Markdown Markdown
<small v-if="textSelection">(selection)</small> <span v-if="textSelection">selection</span>
</span> </span>
<span v-for="stat in textStats" :key="stat.id"> <span v-for="stat in textStats" :key="stat.id">
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}} <span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
@ -13,7 +13,7 @@
<div class="stat-panel__block stat-panel__block--right"> <div class="stat-panel__block stat-panel__block--right">
<span class="stat-panel__block-name"> <span class="stat-panel__block-name">
HTML HTML
<small v-if="htmlSelection">(selection)</small> <span v-if="htmlSelection">selection</span>
</span> </span>
<span v-for="stat in htmlStats" :key="stat.id"> <span v-for="stat in htmlStats" :key="stat.id">
<span class="stat-panel__value">{{stat.value}}</span> {{stat.name}} <span class="stat-panel__value">{{stat.value}}</span> {{stat.name}}
@ -123,10 +123,6 @@ export default {
float: right; float: right;
} }
.stat-panel__block-name {
font-weight: 600;
}
.stat-panel__value { .stat-panel__value {
font-weight: 600; font-weight: 600;
margin-left: 5px; margin-left: 5px;

View File

@ -105,29 +105,39 @@ export default {
} }
.cl-toc-section { .cl-toc-section {
* { h1,
margin: 0.2em 0; h2 {
padding: 0.2em 0; &::after {
border-bottom: 0; display: none;
}
}
h1 {
margin: 1rem 0;
} }
h2 { h2 {
margin: 0.5rem 0;
margin-left: 8px; margin-left: 8px;
} }
h3 { h3 {
margin: 0.33rem 0;
margin-left: 16px; margin-left: 16px;
} }
h4 { h4 {
margin: 0.22rem 0;
margin-left: 24px; margin-left: 24px;
} }
h5 { h5 {
margin: 0.11rem 0;
margin-left: 32px; margin-left: 32px;
} }
h6 { h6 {
margin: 0;
margin-left: 40px; margin-left: 40px;
} }
} }

View File

@ -0,0 +1,85 @@
import cledit from '../../libs/cledit';
import editorSvc from '../../services/editorSvc';
import editorEngineSvc from '../../services/editorEngineSvc';
import utils from '../../services/utils';
let savedSelection;
const nextTickCbs = [];
const nextTickExecCbs = cledit.Utils.debounce(() => {
while (nextTickCbs.length) {
nextTickCbs.shift()();
}
if (savedSelection) {
editorEngineSvc.clEditor.selectionMgr.setSelectionStartEnd(
savedSelection.start, savedSelection.end);
}
savedSelection = null;
});
const nextTick = (cb) => {
nextTickCbs.push(cb);
nextTickExecCbs();
};
const nextTickRestoreSelection = () => {
savedSelection = {
start: editorEngineSvc.clEditor.selectionMgr.selectionStart,
end: editorEngineSvc.clEditor.selectionMgr.selectionEnd,
};
nextTickExecCbs();
};
export default class EditorClassApplier {
constructor(classGetter, offsetGetter, properties) {
this.classGetter = typeof classGetter === 'function' ? classGetter : () => classGetter;
this.offsetGetter = typeof offsetGetter === 'function' ? offsetGetter : () => offsetGetter;
this.properties = properties || {};
this.eltCollection = editorSvc.editorElt.getElementsByClassName(this.classGetter()[0]);
this.lastEltCount = this.eltCollection.length;
this.restoreClass = () => {
if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) {
this.removeClass();
this.applyClass();
}
};
editorEngineSvc.clEditor.on('contentChanged', this.restoreClass);
nextTick(() => this.applyClass());
}
applyClass() {
const offset = this.offsetGetter();
if (offset && offset.start !== offset.end) {
const range = editorEngineSvc.clEditor.selectionMgr.createRange(
Math.min(offset.start, offset.end),
Math.max(offset.start, offset.end),
);
const properties = {
...this.properties,
className: this.classGetter().join(' '),
};
editorEngineSvc.clEditor.watcher.noWatch(() => {
utils.wrapRange(range, properties);
});
if (editorEngineSvc.clEditor.selectionMgr.hasFocus()) {
nextTickRestoreSelection();
}
this.lastEltCount = this.eltCollection.length;
}
}
removeClass() {
editorEngineSvc.clEditor.watcher.noWatch(() => {
utils.unwrapRange(this.eltCollection);
});
if (editorEngineSvc.clEditor.selectionMgr.hasFocus()) {
nextTickRestoreSelection();
}
}
stop() {
editorEngineSvc.clEditor.off('contentChanged', this.restoreClass);
nextTick(() => this.removeClass());
}
}

View File

@ -30,6 +30,16 @@ body {
} }
} }
pre > code {
overflow-x: auto;
white-space: pre;
}
.table-wrapper {
max-width: 100%;
overflow: auto;
}
button, button,
input, input,
select, select,
@ -78,6 +88,7 @@ textarea {
background-image: none; background-image: none;
border: 0; border: 0;
border-radius: $border-radius-base; border-radius: $border-radius-base;
text-decoration: none;
&:active, &:active,
&:focus, &:focus,
@ -195,6 +206,12 @@ textarea {
background-size: contain; background-size: contain;
} }
.hidden-rendering-container {
position: absolute;
width: 500px;
left: -1000px;
}
@media print { @media print {
body { body {
background-color: transparent !important; background-color: transparent !important;

View File

@ -1,14 +1,16 @@
@import '../../node_modules/normalize-scss/sass/normalize'; @import '../../../node_modules/normalize-scss/sass/normalize';
@import '../../node_modules/katex/dist/katex.css'; @import '../../node_modules/katex/dist/katex.css';
@import './variables.scss'; @import './variables.scss';
@import './fonts.scss'; @import './fonts.scss';
@import './prism'; @import './prism';
@import './mermaid';
@include normalize(); @include normalize();
html, html,
body { body {
color: rgba(0, 0, 0, 0.75); color: rgba(0, 0, 0, 0.75);
font-size: 16px;
font-family: $font-family-main; font-family: $font-family-main;
font-variant-ligatures: common-ligatures; font-variant-ligatures: common-ligatures;
line-height: $line-height-base; line-height: $line-height-base;
@ -16,12 +18,6 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
h1,
h2,
h3,
h4,
h5,
h6,
p, p,
blockquote, blockquote,
pre, pre,
@ -37,26 +33,19 @@ h3,
h4, h4,
h5, h5,
h6 { h6 {
margin: 1.8em 0 1.2em; margin: 1.8em 0;
line-height: $line-height-title; line-height: $line-height-title;
padding: 0.33em 0;
} }
h1, h1,
h2 { h2 {
border-bottom: 1px solid $hr-color; &::after {
} content: '';
display: block;
h1 { position: relative;
font-size: 2.2em; top: 0.33em;
} border-bottom: 1px solid $hr-color;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.22em;
} }
ol ul, ol ul,
@ -68,11 +57,12 @@ ol ol {
a { a {
color: $link-color; color: $link-color;
text-decoration: none; text-decoration: underline;
text-decoration-skip: ink;
&:hover, &:hover,
&:focus { &:focus {
text-decoration: underline; text-decoration: none;
} }
} }
@ -89,8 +79,8 @@ samp {
blockquote { blockquote {
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
margin-left: 1em; padding-left: 1.5em;
margin-right: 1em; border-left: 5px solid rgba(0, 0, 0, 0.075);
} }
code { code {
@ -108,9 +98,9 @@ hr {
pre > code { pre > code {
background-color: $code-bg; background-color: $code-bg;
display: block; display: block;
overflow-x: auto;
padding: 0.5em; padding: 0.5em;
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
white-space: pre-wrap;
} }
.toc ul { .toc ul {
@ -126,7 +116,7 @@ table {
td, td,
th { th {
border-right: 1px solid #e0e0e0; border-right: 1px solid #dcdcdc;
padding: 8px 12px; padding: 8px 12px;
&:last-child { &:last-child {
@ -135,7 +125,7 @@ th {
} }
td { td {
border-top: 1px solid #e0e0e0; border-top: 1px solid #dcdcdc;
} }
kbd { kbd {
@ -163,11 +153,6 @@ img {
max-width: 100%; max-width: 100%;
} }
.table-wrapper {
max-width: 100%;
overflow: auto;
}
.footnote { .footnote {
font-size: 0.8em; font-size: 0.8em;
position: relative; position: relative;
@ -175,18 +160,93 @@ img {
vertical-align: top; vertical-align: top;
} }
.export-container { .stackedit__html {
margin-bottom: 180px; margin-bottom: 180px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding-left: 30px;
padding-right: 30px;
> :not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):not([align]) { @media (min-width: 810px) {
text-align: justify; .stackedit__html {
width: 750px;
}
} }
} }
@media (min-width: 768px) { .stackedit__toc {
.export-container { ul {
width: 750px; padding: 0;
a {
margin: 0.5rem 0;
padding: 0.5rem 1rem;
}
ul {
color: #888;
font-size: 0.9em;
a {
margin: 0;
padding: 0.1rem 1rem;
}
}
}
li {
display: block;
}
a {
display: block;
color: inherit;
text-decoration: none;
&:active,
&:focus,
&:hover {
background-color: rgba(0, 0, 0, 0.075);
border-radius: $border-radius-base;
}
}
}
.stackedit__left {
position: fixed;
display: none;
width: 250px;
top: 0;
left: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
@media (min-width: 1060px) {
display: block;
}
}
.stackedit__right {
position: absolute;
right: 0;
top: 0;
left: 0;
@media (min-width: 1060px) {
left: 250px;
}
}
.stackedit--pdf {
blockquote {
// wkhtmltopdf doesn't like borders with transparency
border-left-color: #ececec;
}
.stackedit__html {
padding-left: 0;
padding-right: 0;
} }
} }

View File

@ -0,0 +1,359 @@
/* stylelint-disable color-hex-case, color-hex-length, rule-empty-line-before, comment-empty-line-before */
.mermaid {
font-size: 16px;
svg {
color: rgba(0, 0, 0, 0.75);
width: 100%;
max-width: 100%;
* {
font-family: $font-family-main;
}
}
.mermaid .label {
color: #333;
}
.node rect,
.node circle,
.node ellipse,
.node polygon {
fill: #eee;
stroke: #999;
stroke-width: 1px;
}
.edgePath .path {
stroke: #666;
stroke-width: 1.5px;
}
.edgeLabel {
background-color: white;
}
.cluster rect {
fill: #eaf2fb !important;
rx: 4 !important;
stroke: #26a !important;
stroke-width: 1px !important;
}
.cluster text {
fill: #333;
}
.actor {
stroke: #999;
fill: #eee;
}
text.actor {
fill: #333;
stroke: none;
}
.actor-line {
stroke: #666;
}
.messageLine0 {
stroke-width: 1.5;
stroke-dasharray: "2 2";
marker-end: "url(#arrowhead)";
stroke: #333;
}
.messageLine1 {
stroke-width: 1.5;
stroke-dasharray: "2 2";
stroke: #333;
}
#arrowhead {
fill: #333;
}
#crosshead path {
fill: #333 !important;
stroke: #333 !important;
}
.messageText {
fill: #333;
stroke: none;
}
.labelBox {
stroke: #999;
fill: #eee;
}
.labelText {
fill: white;
stroke: none;
}
.loopText {
fill: white;
stroke: none;
}
.loopLine {
stroke-width: 2;
stroke-dasharray: "2 2";
marker-end: "url(#arrowhead)";
stroke: #999;
}
.note {
stroke: #777700;
fill: #ffa;
}
.noteText {
fill: black;
stroke: none;
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
/** Section styling */
.section {
stroke: none;
opacity: 0.2;
}
.section0 {
fill: #7fb2e6;
}
.section2 {
fill: #7fb2e6;
}
.section1,
.section3 {
fill: white;
opacity: 0.2;
}
.sectionTitle0 {
fill: #333;
}
.sectionTitle1 {
fill: #333;
}
.sectionTitle2 {
fill: #333;
}
.sectionTitle3 {
fill: #333;
}
.sectionTitle {
text-anchor: start;
font-size: 11px;
}
/* Grid and axis */
.grid .tick {
stroke: #e5e5e5;
opacity: 0.3;
shape-rendering: crispEdges;
}
.grid path {
stroke-width: 0;
}
/* Today line */
.today {
fill: none;
stroke: #d42;
stroke-width: 2px;
}
/* Task styling */
/* Default task */
.task {
stroke-width: 2;
}
.taskText {
text-anchor: middle;
font-size: 11px;
}
.taskTextOutsideRight {
fill: #333;
text-anchor: start;
font-size: 11px;
}
.taskTextOutsideLeft {
fill: #333;
text-anchor: end;
font-size: 11px;
}
/* Specific task settings for the sections */
.taskText0,
.taskText1,
.taskText2,
.taskText3 {
fill: white;
}
.task0,
.task1,
.task2,
.task3 {
fill: #26a;
stroke: #194c7f;
}
.taskTextOutside0,
.taskTextOutside2 {
fill: #333;
}
.taskTextOutside1,
.taskTextOutside3 {
fill: #333;
}
/* Active task */
.active0,
.active1,
.active2,
.active3 {
fill: #eee;
stroke: #194c7f;
}
.activeText0,
.activeText1,
.activeText2,
.activeText3 {
fill: #333 !important;
}
/* Completed task */
.done0,
.done1,
.done2,
.done3 {
stroke: #666;
fill: #bbb;
stroke-width: 2;
}
.doneText0,
.doneText1,
.doneText2,
.doneText3 {
fill: #333 !important;
}
/* Tasks on the critical line */
.crit0,
.crit1,
.crit2,
.crit3 {
stroke: #b1361b;
fill: #d42;
stroke-width: 2;
}
.activeCrit0,
.activeCrit1,
.activeCrit2,
.activeCrit3 {
stroke: #b1361b;
fill: #eee;
stroke-width: 2;
}
.doneCrit0,
.doneCrit1,
.doneCrit2,
.doneCrit3 {
stroke: #b1361b;
fill: #bbb;
stroke-width: 2;
cursor: pointer;
}
.doneCritText0,
.doneCritText1,
.doneCritText2,
.doneCritText3 {
fill: #333 !important;
}
.activeCritText0,
.activeCritText1,
.activeCritText2,
.activeCritText3 {
fill: #333 !important;
}
.titleText {
text-anchor: middle;
font-size: 18px;
fill: #333;
}
g.classGroup text {
fill: #999;
stroke: none;
font-family: 'trebuchet ms', verdana, arial;
font-size: 10px;
}
g.classGroup rect {
fill: #eee;
stroke: #999;
}
g.classGroup line {
stroke: #999;
stroke-width: 1;
}
svg .classLabel .box {
stroke: none;
stroke-width: 0;
fill: #eee;
opacity: 0.5;
}
svg .classLabel .label {
fill: #999;
font-size: 10px;
}
.relation {
stroke: #999;
stroke-width: 1;
fill: none;
}
.composition {
fill: #999;
stroke: #999;
stroke-width: 1;
}
#compositionStart {
fill: #999;
stroke: #999;
stroke-width: 1;
}
#compositionEnd {
fill: #999;
stroke: #999;
stroke-width: 1;
}
.aggregation {
fill: #eee;
stroke: #999;
stroke-width: 1;
}
#aggregationStart {
fill: #eee;
stroke: #999;
stroke-width: 1;
}
#aggregationEnd {
fill: #eee;
stroke: #999;
stroke-width: 1;
}
#dependencyStart {
fill: #999;
stroke: #999;
stroke-width: 1;
}
#dependencyEnd {
fill: #999;
stroke: #999;
stroke-width: 1;
}
#extensionStart {
fill: #999;
stroke: #999;
stroke-width: 1;
}
#extensionEnd {
fill: #999;
stroke: #999;
stroke-width: 1;
}
.node text {
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
div.mermaidTooltip {
position: absolute;
text-align: center;
max-width: 200px;
padding: 2px;
font-family: Arial, Helvetica, sans-serif;
font-size: 12px;
background: #eaf2fb;
border: 1px solid #26a;
border-radius: 2px;
pointer-events: none;
z-index: 100;
}
}

View File

@ -12,14 +12,19 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="exportPdf"> <menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download> <icon-download slot="icon"></icon-download>
<div>Export as PDF</div> <div><div class="menu-entry__sponsor">sponsor</div> Export as PDF</div>
<span>Produce a PDF from an HTML template.</span> <span>Produce a PDF from an HTML template.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="exportPandoc">
<icon-download slot="icon"></icon-download>
<div><div class="menu-entry__sponsor">sponsor</div> Export with Pandoc</div>
<span>Convert file to PDF, Word, EPUB...</span>
</menu-entry>
</div> </div>
</template> </template>
<script> <script>
import MenuEntry from './MenuEntry'; import MenuEntry from './common/MenuEntry';
import exportSvc from '../../services/exportSvc'; import exportSvc from '../../services/exportSvc';
export default { export default {
@ -37,7 +42,12 @@ export default {
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
exportPdf() { exportPdf() {
return this.$store.dispatch('modal/notImplemented'); return this.$store.dispatch('modal/open', 'pdfExport')
.catch(() => {}); // Cancel
},
exportPandoc() {
return this.$store.dispatch('modal/open', 'pandocExport')
.catch(() => {}); // Cancel
}, },
}, },
}; };

View File

@ -20,7 +20,7 @@
<menu-entry @click.native="setPanel('publish')"> <menu-entry @click.native="setPanel('publish')">
<icon-upload slot="icon"></icon-upload> <icon-upload slot="icon"></icon-upload>
<div>Publish</div> <div>Publish</div>
<span>Export to the web.</span> <span>Export your file to the web.</span>
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="fileProperties"> <menu-entry @click.native="fileProperties">
@ -63,7 +63,7 @@
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './MenuEntry'; import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage'; import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';

View File

@ -34,21 +34,22 @@
<icon-help-circle slot="icon"></icon-help-circle> <icon-help-circle slot="icon"></icon-help-circle>
<span>About StackEdit</span> <span>About StackEdit</span>
</menu-entry> </menu-entry>
<a href="editor" target="_blank" class="menu-entry button flex flex--row flex--align-center"> <menu-entry @click.native="welcomeFile">
<div class="menu-entry__icon flex flex--column flex--center"> <icon-file slot="icon"></icon-file>
<icon-open-in-new></icon-open-in-new> <span>Welcome file</span>
</div> </menu-entry>
<div class="flex flex--column"> <menu-entry href="editor" target="_blank">
<span>Go back to StackEdit 4</span> <icon-open-in-new slot="icon"></icon-open-in-new>
</div> <span>StackEdit 4 (deprecated)</span>
</a> </menu-entry>
</div> </div>
</template> </template>
<script> <script>
import MenuEntry from './MenuEntry'; import MenuEntry from './common/MenuEntry';
import localDbSvc from '../../services/localDbSvc'; import localDbSvc from '../../services/localDbSvc';
import backupSvc from '../../services/backupSvc'; import backupSvc from '../../services/backupSvc';
import welcomeFile from '../../data/welcomeFile.md';
export default { export default {
components: { components: {
@ -92,6 +93,13 @@ export default {
() => {}, // Cancel () => {}, // Cancel
); );
}, },
welcomeFile() {
return this.$store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})
.then(createdFile => this.$store.commit('file/setCurrentId', createdFile.id));
},
about() { about() {
return this.$store.dispatch('modal/open', 'about'); return this.$store.dispatch('modal/open', 'about');
}, },

View File

@ -96,7 +96,7 @@
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import MenuEntry from './MenuEntry'; import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper'; import githubHelper from '../../services/providers/helpers/githubHelper';

View File

@ -44,6 +44,11 @@
</menu-entry> </menu-entry>
</div> </div>
<div v-for="token in githubTokens" :key="token.sub"> <div v-for="token in githubTokens" :key="token.sub">
<menu-entry @click.native="openGithub(token)">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<div>Open from GitHub</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveGithub(token)"> <menu-entry @click.native="saveGithub(token)">
<icon-provider slot="icon" provider-id="github"></icon-provider> <icon-provider slot="icon" provider-id="github"></icon-provider>
<div>Save on GitHub</div> <div>Save on GitHub</div>
@ -73,12 +78,13 @@
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import MenuEntry from './MenuEntry'; import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper'; import githubHelper from '../../services/providers/helpers/githubHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider'; import googleDriveProvider from '../../services/providers/googleDriveProvider';
import dropboxProvider from '../../services/providers/dropboxProvider'; import dropboxProvider from '../../services/providers/dropboxProvider';
import githubProvider from '../../services/providers/githubProvider';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';
import store from '../../store'; import store from '../../store';
@ -161,15 +167,23 @@ export default {
() => dropboxProvider.openFiles(token, paths))); () => dropboxProvider.openFiles(token, paths)));
}, },
saveGoogleDrive(token) { saveGoogleDrive(token) {
return openSyncModal(token, 'googleDriveSync') return openSyncModal(token, 'googleDriveSave')
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
saveDropbox(token) { saveDropbox(token) {
return openSyncModal(token, 'dropboxSync') return openSyncModal(token, 'dropboxSave')
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
openGithub(token) {
return store.dispatch('modal/open', {
type: 'githubOpen',
token,
})
.then(syncLocation => this.$store.dispatch('queue/enqueue',
() => githubProvider.openFile(token, syncLocation)));
},
saveGithub(token) { saveGithub(token) {
return openSyncModal(token, 'githubSync') return openSyncModal(token, 'githubSave')
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
saveGist(token) { saveGist(token) {

View File

@ -3,26 +3,34 @@
<div class="menu-entry__icon flex flex--column flex--center"> <div class="menu-entry__icon flex flex--column flex--center">
<slot name="icon"></slot> <slot name="icon"></slot>
</div> </div>
<div class="flex flex--column"> <div class="menu-entry__text flex flex--column">
<slot></slot> <slot></slot>
</div> </div>
</a> </a>
</template> </template>
<style lang="scss"> <style lang="scss">
@import '../common/variables.scss'; @import '../../common/variables.scss';
.menu-entry { .menu-entry {
text-align: left; text-align: left;
padding: 10px; padding: 10px;
height: auto; height: auto;
div div {
text-decoration: underline;
text-decoration-skip: ink;
&.menu-entry__sponsor {
text-decoration: none;
}
}
span { span {
display: inline-block; display: inline-block;
font-size: 0.75em; font-size: 0.75rem;
opacity: 0.5; opacity: 0.5;
white-space: normal; white-space: normal;
line-height: 1.4;
} }
} }
@ -42,4 +50,18 @@
position: fixed; position: fixed;
top: -999px; top: -999px;
} }
.menu-entry__sponsor {
float: right;
font-size: 0.6rem;
font-weight: 600;
padding: 0.05em 0.25em;
background-color: darken($error-color, 10);
border-radius: 3px;
color: #fff;
}
.menu-entry__text {
width: 100%;
}
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--about-modal" role="dialog" aria-label="About"> <modal-inner class="modal__inner-1--about-modal" aria-label="About">
<div class="modal__inner-2"> <div class="modal__content">
<div class="logo-background"></div> <div class="logo-background"></div>
<div class="app-version">v{{version}} © 2017 Benoit Schweblin</div> <div class="app-version">v{{version}} © 2017 Benoit Schweblin</div>
<hr> <hr>
@ -12,17 +12,21 @@
<a target="_blank" href="https://twitter.com/stackedit/">StackEdit on Twitter</a> <a target="_blank" href="https://twitter.com/stackedit/">StackEdit on Twitter</a>
<hr> <hr>
<a target="_blank" href="privacy_policy.html">Privacy Policy</a> <a target="_blank" href="privacy_policy.html">Privacy Policy</a>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default { export default {
components: {
ModalInner,
},
data: () => ({ data: () => ({
version: VERSION, version: VERSION,
}), }),
@ -38,7 +42,7 @@ export default {
.logo-background { .logo-background {
height: 75px; height: 75px;
margin-bottom: 0.5rem; margin: 0.5rem 0;
} }
.app-version { .app-version {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--file-properties" role="dialog" aria-label="File properties"> <modal-inner class="modal__inner-1--file-properties" aria-label="File properties">
<div class="modal__inner-2"> <div class="modal__content">
<div class="tabs flex flex--row"> <div class="tabs flex flex--row">
<tab :active="tab === 'custom'" @click="tab = 'custom'"> <tab :active="tab === 'custom'" @click="tab = 'custom'">
Current file properties Current file properties
@ -22,26 +22,30 @@
</div> </div>
</div> </div>
<div class="modal__error modal__error--file-properties">{{error}}</div> <div class="modal__error modal__error--file-properties">{{error}}</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Tab from './Tab'; import ModalInner from './common/ModalInner';
import Tab from './common/Tab';
import CodeEditor from '../CodeEditor'; import CodeEditor from '../CodeEditor';
import utils from '../../services/utils'; import utils from '../../services/utils';
import defaultProperties from '../../data/defaultFileProperties.yml'; import defaultProperties from '../../data/defaultFileProperties.yml';
const emptyProperties = '# Add custom properties for the current file here to override the default properties.\n'; const emptyProperties = `# Add custom properties for the current file here
# to override the default properties.
`;
export default { export default {
components: { components: {
ModalInner,
Tab, Tab,
CodeEditor, CodeEditor,
}, },

View File

@ -1,8 +1,9 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Export to HTML"> <modal-inner aria-label="Export to HTML">
<div class="modal__inner-2"> <div class="modal__content">
<p>Please choose a template for your <b>HTML export</b>.</p>
<form-entry label="Template"> <form-entry label="Template">
<select slot="field" class="textfield" v-model="selectedTemplate" @keyup.enter="resolve()"> <select class="textfield" slot="field" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" :value="id"> <option v-for="(template, id) in allTemplates" :key="id" :value="id">
{{ template.name }} {{ template.name }}
</option> </option>
@ -11,19 +12,19 @@
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a> <a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button button--copy">Copy to clipboard</button>
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button button--copy">Copy to clipboard</button>
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import Clipboard from 'clipboard'; import Clipboard from 'clipboard';
import exportSvc from '../../services/exportSvc'; import exportSvc from '../../services/exportSvc';
import modalTemplate from './modalTemplate'; import modalTemplate from './common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({
@ -33,12 +34,16 @@ export default modalTemplate({
selectedTemplate: 'htmlExportTemplate', selectedTemplate: 'htmlExportTemplate',
}, },
mounted() { mounted() {
let timeoutId;
this.$watch('selectedTemplate', (selectedTemplate) => { this.$watch('selectedTemplate', (selectedTemplate) => {
const currentFile = this.$store.getters['file/current']; clearTimeout(timeoutId);
exportSvc.applyTemplate(currentFile.id, this.allTemplates[selectedTemplate]) timeoutId = setTimeout(() => {
.then((html) => { const currentFile = this.$store.getters['file/current'];
this.result = html; exportSvc.applyTemplate(currentFile.id, this.allTemplates[selectedTemplate])
}); .then((html) => {
this.result = html;
});
}, 10);
}, { }, {
immediate: true, immediate: true,
}); });
@ -51,9 +56,10 @@ export default modalTemplate({
}, },
methods: { methods: {
resolve() { resolve() {
const config = this.config;
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
config.resolve();
exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplates[this.selectedTemplate]); exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplates[this.selectedTemplate]);
this.config.resolve();
}, },
}, },
}); });

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Insert image"> <modal-inner aria-label="Insert image">
<div class="modal__inner-2"> <div class="modal__content">
<p>Please provide a <b>URL</b> for your image. <p>Please provide a <b>URL</b> for your image.</p>
<form-entry label="URL" error="url"> <form-entry label="URL" error="url">
<input slot="field" class="textfield" type="text" v-model.trim="url" @keyup.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="url" @keyup.enter="resolve()">
</form-entry> </form-entry>
@ -14,17 +14,17 @@
<icon-provider slot="icon" provider-id="googlePhotos"></icon-provider> <icon-provider slot="icon" provider-id="googlePhotos"></icon-provider>
<span>Add Google Photos account</span> <span>Add Google Photos account</span>
</menu-entry> </menu-entry>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import modalTemplate from './modalTemplate'; import modalTemplate from './common/modalTemplate';
import MenuEntry from '../menus/MenuEntry'; import MenuEntry from '../menus/common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
export default modalTemplate({ export default modalTemplate({

View File

@ -1,20 +1,20 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Insert link"> <modal-inner aria-label="Insert link">
<div class="modal__inner-2"> <div class="modal__content">
<p>Please provide a <b>URL</b> for your link. <p>Please provide a <b>URL</b> for your link.</p>
<form-entry label="URL" error="url"> <form-entry label="URL" error="url">
<input slot="field" class="textfield" type="text" v-model.trim="url" @keyup.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="url" @keyup.enter="resolve()">
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import modalTemplate from './modalTemplate'; import modalTemplate from './common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -0,0 +1,82 @@
<template>
<modal-inner aria-label="Export with Pandoc">
<div class="modal__content">
<p>Please choose a format for your <b>Pandoc export</b>.</p>
<form-entry label="Template">
<select class="textfield" slot="field" v-model="selectedFormat" @keyup.enter="resolve()">
<option value="asciidoc">AsciiDoc</option>
<option value="context">ConTeXt</option>
<option value="epub">EPUB</option>
<option value="epub3">EPUB v3</option>
<option value="latex">LaTeX</option>
<option value="odt">OpenOffice</option>
<option value="pdf">PDF</option>
<option value="rst">reStructuredText</option>
<option value="rtf">Rich Text Format</option>
<option value="textile">Textile</option>
<option value="docx">Word</option>
</select>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import FileSaver from 'file-saver';
import sponsorSvc from '../../services/sponsorSvc';
import networkSvc from '../../services/networkSvc';
import editorSvc from '../../services/editorSvc';
import googleHelper from '../../services/providers/helpers/googleHelper';
import modalTemplate from './common/modalTemplate';
export default modalTemplate({
computedLocalSettings: {
selectedFormat: 'pandocExportFormat',
},
methods: {
resolve() {
this.config.resolve();
const currentFile = this.$store.getters['file/current'];
const currentContent = this.$store.getters['content/current'];
const selectedFormat = this.selectedFormat;
this.$store.dispatch('queue/enqueue', () => Promise.all([
Promise.resolve().then(() => {
const loginToken = this.$store.getters['data/loginToken'];
return loginToken && googleHelper.refreshToken(loginToken);
}),
sponsorSvc.getToken(),
])
.then(([loginToken, token]) => networkSvc.request({
method: 'POST',
url: 'pandocExport',
params: {
token,
idToken: loginToken && loginToken.idToken,
format: selectedFormat,
options: JSON.stringify(this.$store.getters['data/computedSettings'].pandoc),
metadata: JSON.stringify(currentContent.properties),
},
body: JSON.stringify(editorSvc.getPandocAst()),
blob: true,
timeout: 60000,
})
.then((res) => {
FileSaver.saveAs(res.body, `${currentFile.name}.${selectedFormat}`);
}, (err) => {
if (err.status !== 401) {
throw err;
}
this.$store.dispatch('modal/sponsorOnly');
}))
.catch((err) => {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);
}));
},
},
});
</script>

View File

@ -0,0 +1,75 @@
<template>
<modal-inner aria-label="Export to PDF">
<div class="modal__content">
<p>Please choose a template for your <b>PDF export</b>.</p>
<form-entry label="Template">
<select class="textfield" slot="field" v-model="selectedTemplate" @keyup.enter="resolve()">
<option v-for="(template, id) in allTemplates" :key="id" :value="id">
{{ template.name }}
</option>
</select>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import FileSaver from 'file-saver';
import exportSvc from '../../services/exportSvc';
import sponsorSvc from '../../services/sponsorSvc';
import networkSvc from '../../services/networkSvc';
import googleHelper from '../../services/providers/helpers/googleHelper';
import modalTemplate from './common/modalTemplate';
export default modalTemplate({
computedLocalSettings: {
selectedTemplate: 'pdfExportTemplate',
},
methods: {
resolve() {
this.config.resolve();
const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue', () => Promise.all([
Promise.resolve().then(() => {
const loginToken = this.$store.getters['data/loginToken'];
return loginToken && googleHelper.refreshToken(loginToken);
}),
sponsorSvc.getToken(),
exportSvc.applyTemplate(
currentFile.id, this.allTemplates[this.selectedTemplate], true),
])
.then(([loginToken, token, html]) => networkSvc.request({
method: 'POST',
url: 'pdfExport',
params: {
token,
idToken: loginToken && loginToken.idToken,
options: JSON.stringify(this.$store.getters['data/computedSettings'].wkhtmltopdf),
},
body: html,
blob: true,
timeout: 60000,
})
.then((res) => {
FileSaver.saveAs(res.body, `${currentFile.name}.pdf`);
}, (err) => {
if (err.status !== 401) {
throw err;
}
this.$store.dispatch('modal/sponsorOnly');
}))
.catch((err) => {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);
}));
},
},
});
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--publish-management" role="dialog" aria-label="Manage publication locations"> <modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations">
<div class="modal__inner-2"> <div class="modal__content">
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p> <p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not published yet.</p> <p v-else><b>{{currentFileName}}</b> is not published yet.</p>
<div> <div>
@ -24,18 +24,22 @@
<div class="modal__info" v-if="publishLocations.length"> <div class="modal__info" v-if="publishLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file. <b>Note:</b> Removing a synchronized location won't delete any file.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default { export default {
components: {
ModalInner,
},
computed: { computed: {
...mapGetters('modal', [ ...mapGetters('modal', [
'config', 'config',

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--settings" role="dialog" aria-label="Settings"> <modal-inner class="modal__inner-1--settings" aria-label="Settings">
<div class="modal__inner-2"> <div class="modal__content">
<div class="tabs flex flex--row"> <div class="tabs flex flex--row">
<tab :active="tab === 'custom'" @click="tab = 'custom'"> <tab :active="tab === 'custom'" @click="tab = 'custom'">
Custom settings Custom settings
@ -22,25 +22,29 @@
</div> </div>
</div> </div>
<div class="modal__error modal__error--settings">{{error}}</div> <div class="modal__error modal__error--settings">{{error}}</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Tab from './Tab'; import ModalInner from './common/ModalInner';
import Tab from './common/Tab';
import CodeEditor from '../CodeEditor'; import CodeEditor from '../CodeEditor';
import defaultSettings from '../../data/defaultSettings.yml'; import defaultSettings from '../../data/defaultSettings.yml';
const emptySettings = '# Add your custom settings here to override the default settings.\n'; const emptySettings = `# Add your custom settings here to override the
# default settings.
`;
export default { export default {
components: { components: {
ModalInner,
Tab, Tab,
CodeEditor, CodeEditor,
}, },

View File

@ -0,0 +1,98 @@
<template>
<modal-inner class="modal__inner-1--sponsor" aria-label="Sponsor">
<div class="modal__content">
<p>Please choose a <b>PayPal</b> option.</p>
<a class="paypal-option button flex flex--row flex--center" v-for="button in buttons" :key="button.id" :href="button.link">
<div class="flex flex--column">
<div>{{button.price}}<div class="paypal-option__offer" v-if="button.offer">{{button.offer}}</div></div>
<span>{{button.description}}</span>
</div>
</a>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
</div>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
import utils from '../../services/utils';
export default {
components: {
ModalInner,
},
data() {
const loginToken = this.$store.getters['data/loginToken'];
const makeButton = (id, price, description, offer) => {
const params = {
cmd: '_s-xclick',
hosted_button_id: id,
custom: loginToken.sub,
};
return {
id,
price,
description,
offer,
link: utils.addQueryParams('https://www.paypal.com/cgi-bin/webscr', params),
};
};
return {
buttons: loginToken ? [
makeButton('TDAPH47B3J2JW', '$5', '3 months sponsorship'),
makeButton('6CTKPKF8868UA', '$15', '1 year sponsorship', '-25%'),
makeButton('A5ZDYW6SYDLBE', '$25', '2 years sponsorship', '-37%'),
makeButton('3DMD3TT2RDPQA', '$50', '5 years sponsorship', '-50%'),
] : [],
};
},
computed: {
...mapGetters('modal', [
'config',
]),
},
methods: {
sponsor() {
},
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.modal__inner-1--sponsor {
max-width: 380px;
}
.paypal-option {
text-align: center;
padding: 10px;
height: auto;
font-size: 2.3em;
margin: 0.75rem 0;
line-height: 1.2;
span {
display: inline-block;
font-size: 0.75rem;
opacity: 0.5;
white-space: normal;
}
.paypal-option__offer {
float: right;
font-size: 0.6rem;
font-weight: 600;
padding: 0.1em 0.2em;
background-color: darken($error-color, 10);
border-radius: 3px;
color: #fff;
margin-left: -0.5em;
}
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--sync-management" role="dialog" aria-label="Manage synchronized locations"> <modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations">
<div class="modal__inner-2"> <div class="modal__content">
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p> <p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p> <p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
<div> <div>
@ -24,18 +24,22 @@
<div class="modal__info" v-if="syncLocations.length"> <div class="modal__info" v-if="syncLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file. <b>Note:</b> Removing a synchronized location won't delete any file.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default { export default {
components: {
ModalInner,
},
computed: { computed: {
...mapGetters('modal', [ ...mapGetters('modal', [
'config', 'config',

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--templates" role="dialog" aria-label="Manage templates"> <modal-inner class="modal__inner-1--templates" aria-label="Manage templates">
<div class="modal__inner-2"> <div class="modal__content">
<div class="form-entry"> <div class="form-entry">
<label class="form-entry__label" for="template">Template</label> <label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field"> <div class="form-entry__field">
@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div v-if="!isReadOnly"> <div v-if="!isReadOnly">
<a href="javascript:void(0)" v-if="!showHelpers" @click="showHelpers = true">Add helpers </a> <a href="javascript:void(0)" v-if="!showHelpers" @click="showHelpers = true">Add helpers</a>
<div class="form-entry" v-else> <div class="form-entry" v-else>
<br> <br>
<label class="form-entry__label">Helpers</label> <label class="form-entry__label">Helpers</label>
@ -42,17 +42,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import utils from '../../services/utils'; import utils from '../../services/utils';
import ModalInner from './common/ModalInner';
import CodeEditor from '../CodeEditor'; import CodeEditor from '../CodeEditor';
import emptyTemplateValue from '../../data/emptyTemplateValue.html'; import emptyTemplateValue from '../../data/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../../data/emptyTemplateHelpers.js'; // eslint-disable-line import emptyTemplateHelpers from '!raw-loader!../../data/emptyTemplateHelpers.js'; // eslint-disable-line
@ -70,6 +71,7 @@ function fillEmptyFields(template) {
export default { export default {
components: { components: {
ModalInner,
CodeEditor, CodeEditor,
}, },
data: () => ({ data: () => ({

View File

@ -9,7 +9,7 @@
</template> </template>
<script> <script>
import utils from '../../services/utils'; import utils from '../../../services/utils';
export default { export default {
props: ['label', 'error'], props: ['label', 'error'],

View File

@ -0,0 +1,71 @@
<template>
<div class="modal__inner-1" role="dialog">
<div class="modal__inner-2">
<button class="modal__close-button button not-tabbable" @click="config.reject()" v-title="'Close modal'">
<icon-close></icon-close>
</button>
<div class="modal__sponsor-button" v-if="showSponsorButton">
Please consider
<a class="not-tabbable" href="javascript:void(0)" @click="sponsor">sponsoring StackEdit</a> for just $5.
</div>
<slot></slot>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import googleHelper from '../../../services/providers/helpers/googleHelper';
import syncSvc from '../../../services/syncSvc';
export default {
computed: {
...mapGetters('modal', [
'config',
]),
showSponsorButton() {
return !this.$store.getters.isSponsor && this.$store.getters['modal/config'].type !== 'sponsor';
},
},
methods: {
sponsor() {
Promise.resolve()
.then(() => !this.$store.getters['data/loginToken'] &&
this.$store.dispatch('modal/signInRequired') // If user has to sign in
.then(() => googleHelper.signin())
.then(() => syncSvc.requestSync()))
.then(() => this.$store.dispatch('modal/open', 'sponsor'))
.catch(() => { }); // Cancel
},
},
};
</script>
<style lang="scss">
@import '../../common/variables.scss';
.modal__close-button {
position: absolute;
top: 8px;
right: 8px;
color: rgba(0, 0, 0, 0.2);
width: 30px;
height: 30px;
padding: 2px;
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.3);
}
}
.modal__sponsor-button {
display: inline-block;
color: darken($error-color, 10%);
background-color: transparentize($error-color, 0.85);
border-radius: $border-radius-base;
padding: 1em;
margin-bottom: 1.2em;
}
</style>

View File

@ -1,5 +1,8 @@
import ModalInner from './ModalInner';
import FormEntry from './FormEntry'; import FormEntry from './FormEntry';
import store from '../../store'; import store from '../../../store';
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
export default (desc) => { export default (desc) => {
const component = { const component = {
@ -10,6 +13,7 @@ export default (desc) => {
}), }),
components: { components: {
...desc.components || {}, ...desc.components || {},
ModalInner,
FormEntry, FormEntry,
}, },
computed: { computed: {
@ -49,8 +53,18 @@ export default (desc) => {
}, },
}; };
if (key === 'selectedTemplate') { if (key === 'selectedTemplate') {
component.computed.allTemplates = () => store.getters['data/allTemplates']; component.computed.allTemplates = () => {
component.methods.configureTemplates = () => { const allTemplates = store.getters['data/allTemplates'];
const sortedTemplates = {};
Object.keys(allTemplates)
.sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name))
.forEach((templateId) => {
sortedTemplates[templateId] = allTemplates[templateId];
});
return sortedTemplates;
};
// Make use of `function` to have `this` bound to the component
component.methods.configureTemplates = function () { // eslint-disable-line func-names
store.dispatch('modal/open', { store.dispatch('modal/open', {
type: 'templates', type: 'templates',
selectedId: this.selectedTemplate, selectedId: this.selectedTemplate,

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Blogger Page"> <modal-inner aria-label="Publish to Blogger Page">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="bloggerPage"></icon-provider> <icon-provider provider-id="bloggerPage"></icon-provider>
</div> </div>
@ -27,17 +27,17 @@
<div class="modal__info"> <div class="modal__info">
<b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import bloggerPageProvider from '../../services/providers/bloggerPageProvider'; import bloggerPageProvider from '../../../services/providers/bloggerPageProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Blogger"> <modal-inner aria-label="Publish to Blogger">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="blogger"></icon-provider> <icon-provider provider-id="blogger"></icon-provider>
</div> </div>
@ -28,17 +28,17 @@
<b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code>, <b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code>,
<code>status</code> and <code>date</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <code>status</code> and <code>date</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import bloggerProvider from '../../services/providers/bloggerProvider'; import bloggerProvider from '../../../services/providers/bloggerProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Link Dropbox account"> <modal-inner aria-label="Link Dropbox account">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
@ -15,16 +15,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
computedLocalSettings: { computedLocalSettings: {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Dropbox"> <modal-inner aria-label="Publish to Dropbox">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
@ -22,17 +22,17 @@
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a> <a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import dropboxProvider from '../../services/providers/dropboxProvider'; import dropboxProvider from '../../../services/providers/dropboxProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Dropbox"> <modal-inner aria-label="Synchronize with Dropbox">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
@ -12,17 +12,17 @@
If the file exists, it will be replaced. If the file exists, it will be replaced.
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import dropboxProvider from '../../services/providers/dropboxProvider'; import dropboxProvider from '../../../services/providers/dropboxProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Gist"> <modal-inner aria-label="Publish to Gist">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gist"></icon-provider> <icon-provider provider-id="gist"></icon-provider>
</div> </div>
@ -34,17 +34,17 @@
<div class="modal__info"> <div class="modal__info">
<b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import gistProvider from '../../services/providers/gistProvider'; import gistProvider from '../../../services/providers/gistProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Gist"> <modal-inner aria-label="Synchronize with Gist">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gist"></icon-provider> <icon-provider provider-id="gist"></icon-provider>
</div> </div>
@ -21,17 +21,17 @@
If the file exists in the Gist, it will be replaced. If the file exists in the Gist, it will be replaced.
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import gistProvider from '../../services/providers/gistProvider'; import gistProvider from '../../../services/providers/gistProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Link GitHub account"> <modal-inner aria-label="Link GitHub account">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
@ -8,20 +8,20 @@
<div class="form-entry"> <div class="form-entry">
<div class="form-entry__checkbox"> <div class="form-entry__checkbox">
<label> <label>
<input type="checkbox" v-model="repoFullAccess"> Request access to private repositories <input type="checkbox" v-model="repoFullAccess"> Grant access to my <b>private repositories</b>
</label> </label>
</div> </div>
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
computedLocalSettings: { computedLocalSettings: {

View File

@ -0,0 +1,68 @@
<template>
<modal-inner aria-label="Synchronize with GitHub">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>This will open a file from your <b>GitHub</b> repository and keep it synchronized.</p>
<form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keyup.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</form-entry>
<form-entry label="Branch (optional)">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keyup.enter="resolve()">
<div class="form-entry__info">
If not provided, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keyup.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> docs/README.md
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'githubRepoUrl',
},
methods: {
resolve() {
if (!this.repoUrl) {
this.setError('repoUrl');
}
if (!this.path) {
this.setError('path');
}
if (this.repoUrl && this.path) {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {
// Return new location
const location = githubProvider.makeLocation(
this.config.token, parsedRepo.owner, parsedRepo.repo, this.branch || 'master', this.path);
this.config.resolve(location);
}
}
},
},
});
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to GitHub"> <modal-inner aria-label="Publish to GitHub">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
@ -34,17 +34,17 @@
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a> <a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import githubProvider from '../../services/providers/githubProvider'; import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with GitHub"> <modal-inner aria-label="Synchronize with GitHub">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
@ -14,7 +14,7 @@
<form-entry label="Branch (optional)"> <form-entry label="Branch (optional)">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keyup.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="branch" @keyup.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If not provided, the master branch will be used. If not provided, the <code>master</code> branch will be used.
</div> </div>
</form-entry> </form-entry>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
@ -24,17 +24,17 @@
If the file exists, it will be replaced. If the file exists, it will be replaced.
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import githubProvider from '../../services/providers/githubProvider'; import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({
@ -56,13 +56,13 @@ export default modalTemplate({
this.setError('path'); this.setError('path');
} }
if (this.repoUrl && this.path) { if (this.repoUrl && this.path) {
const parsedRepo = this.repoUrl.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git|\/)?$/); const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
if (!parsedRepo) { if (!parsedRepo) {
this.setError('repoUrl'); this.setError('repoUrl');
} else { } else {
// Return new location // Return new location
const location = githubProvider.makeLocation( const location = githubProvider.makeLocation(
this.config.token, parsedRepo[1], parsedRepo[2], this.branch || 'master', this.path); this.config.token, parsedRepo.owner, parsedRepo.repo, this.branch || 'master', this.path);
this.config.resolve(location); this.config.resolve(location);
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Google Drive"> <modal-inner aria-label="Publish to Google Drive">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
@ -45,18 +45,18 @@
<div class="modal__info"> <div class="modal__info">
<b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <b>ProTip:</b> You can provide a value for <code>title</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider'; import googleDriveProvider from '../../../services/providers/googleDriveProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Google Drive"> <modal-inner aria-label="Synchronize with Google Drive">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
@ -20,18 +20,18 @@
This will overwrite the file on the server. This will overwrite the file on the server.
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider'; import googleDriveProvider from '../../../services/providers/googleDriveProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1 modal__inner-1--google-photo" role="dialog" aria-label="Import Google Photo"> <modal-inner class="modal__inner-1--google-photo" aria-label="Import Google Photo">
<div class="modal__inner-2"> <div class="modal__content">
<div class="google-photo__tumbnail" :style="{'background-image': thumbnailUrl}"></div> <div class="google-photo__tumbnail" :style="{'background-image': thumbnailUrl}"></div>
<form-entry label="Title (optional)"> <form-entry label="Title (optional)">
<input slot="field" class="textfield" type="text" v-model.trim="title" @keyup.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="title" @keyup.enter="resolve()">
@ -8,17 +8,17 @@
<form-entry label="Size limit (optional)"> <form-entry label="Size limit (optional)">
<input slot="field" class="textfield" type="text" v-model.trim="size" @keyup.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="size" @keyup.enter="resolve()">
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import FormEntry from './FormEntry'; import FormEntry from '../common/FormEntry';
const makeThumbnail = (url, size) => `${url}=s${size}`; const makeThumbnail = (url, size) => `${url}=s${size}`;

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to WordPress"> <modal-inner aria-label="Publish to WordPress">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="wordpress"></icon-provider> <icon-provider provider-id="wordpress"></icon-provider>
</div> </div>
@ -30,17 +30,17 @@
<code>categories</code>, <code>excerpt</code>, <code>author</code>, <code>featuredImage</code>, <code>categories</code>, <code>excerpt</code>, <code>author</code>, <code>featuredImage</code>,
<code>status</code> and <code>date</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <code>status</code> and <code>date</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import wordpressProvider from '../../services/providers/wordpressProvider'; import wordpressProvider from '../../../services/providers/wordpressProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Link Zendesk account"> <modal-inner aria-label="Link Zendesk account">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider> <icon-provider provider-id="zendesk"></icon-provider>
</div> </div>
@ -18,17 +18,17 @@
<a href="https://support.zendesk.com/hc/en-us/articles/203663836" target="_blank"><b>More info</b></a> <a href="https://support.zendesk.com/hc/en-us/articles/203663836" target="_blank"><b>More info</b></a>
</div> </div>
</form-entry> </form-entry>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import modalTemplate from './modalTemplate'; import utils from '../../../services/utils';
import utils from '../../services/utils'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Zendesk"> <modal-inner aria-label="Publish to Zendesk">
<div class="modal__inner-2"> <div class="modal__content">
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider> <icon-provider provider-id="zendesk"></icon-provider>
</div> </div>
@ -34,17 +34,17 @@
<b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code> and <b>ProTip:</b> You can provide values for <code>title</code>, <code>tags</code> and
<code>status</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>. <code>status</code> in the <a href="javascript:void(0)" @click="openFileProperties">file properties</a>.
</div> </div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div> </div>
</div> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template> </template>
<script> <script>
import zendeskProvider from '../../services/providers/zendeskProvider'; import zendeskProvider from '../../../services/providers/zendeskProvider';
import modalTemplate from './modalTemplate'; import modalTemplate from '../common/modalTemplate';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({

View File

@ -0,0 +1 @@
@import './common/base';

View File

@ -1,4 +1,5 @@
# File properties can contain metadata used for your publications (Wordpress, Blogger...). # File properties can contain metadata used
# for your publications (Wordpress, Blogger...).
# For example: # For example:
#title: My article #title: My article
@ -26,7 +27,7 @@ extensions:
sup: true sup: true
table: true table: true
typographer: true typographer: true
# For strict CommonMark: # Enable strict CommonMark:
#abbr: false #abbr: false
#deflist: false #deflist: false
#del: false #del: false
@ -37,6 +38,25 @@ extensions:
#table: false #table: false
#typographer: false #typographer: false
# Emoji extension
emoji:
# Enable support for emojis & emoticons
enabled: true
# Enable shortcuts like :) :-(
shortcuts: false
# Katex extension # Katex extension
# Render LaTeX mathematical expressions by using:
# $...$ for inline formulas
# $$...$$ for displayed formulas.
# See https://math.meta.stackexchange.com/questions/5020
katex: katex:
enabled: true enabled: true
# Mermaid extension
# Convert code blocks starting with:
# ```mermaid
# into diagrams and flowcharts.
# See https://mermaidjs.github.io/
mermaid:
enabled: true

View File

@ -7,8 +7,12 @@ export default () => ({
showExplorer: false, showExplorer: false,
scrollSync: true, scrollSync: true,
focusMode: false, focusMode: false,
findCaseSensitive: false,
findUseRegexp: false,
sideBarPanel: 'menu', sideBarPanel: 'menu',
htmlExportTemplate: 'styledHtml', htmlExportTemplate: 'styledHtml',
pdfExportTemplate: 'styledHtml',
pandocExportFormat: 'pdf',
googleDriveFolderId: '', googleDriveFolderId: '',
googleDrivePublishFormat: 'markdown', googleDrivePublishFormat: 'markdown',
googleDrivePublishTemplate: 'styledHtml', googleDrivePublishTemplate: 'styledHtml',

View File

@ -10,9 +10,13 @@ editor:
# Use monospaced font only # Use monospaced font only
monospacedFontOnly: false monospacedFontOnly: false
# Keyboard shortcuts (see https://craig.is/killing/mice) # Keyboard shortcuts
# See https://craig.is/killing/mice
shortcuts: shortcuts:
mod+s: sync mod+s: sync
mod+f: find
mod+alt+f: replace
mod+g: replace
mod+shift+b: bold mod+shift+b: bold
mod+shift+i: italic mod+shift+i: italic
mod+shift+l: link mod+shift+l: link
@ -34,6 +38,23 @@ shortcuts:
- '<== ' - '<== '
- '⇐ ' - '⇐ '
# Options passed to wkhtmltopdf
# See https://wkhtmltopdf.org/usage/wkhtmltopdf.txt
wkhtmltopdf:
marginTop: 25
marginRight: 25
marginBottom: 25
marginLeft: 25
# A3, A4, Legal or Letter
pageSize: A4
# Options passed to pandoc
# See https://pandoc.org/MANUAL.html
pandoc:
highlightStyle: kate
toc: true
tocDepth: 3
# Default content for new files # Default content for new files
newFileContent: | newFileContent: |

View File

@ -19,6 +19,16 @@ The following JavaScript context will be passed to the template:
}] }]
} }
As an example:
<html><body>{{{files.0.content.html}}}</body></html>
will produce:
<html><body><p>The file content</p></body></html>
You can use Handlebars built-in helpers and the custom StackEdit ones: You can use Handlebars built-in helpers and the custom StackEdit ones:
{{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a clickable TOC. {{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a clickable TOC.

View File

@ -5,12 +5,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{files.0.name}}</title> <title>{{files.0.name}}</title>
<link rel="stylesheet" href="http://app.classeur.io/base-min.css" /> <link rel="stylesheet" href="https://stackedit.io/style.css" />
<script type="text/javascript" src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"></script>
</head> </head>
<body> {{#if pdf}}
<div class="export-container">{{{files.0.content.html}}}</div> <body class="stackedit stackedit--pdf">
{{else}}
<body class="stackedit">
{{/if}}
<div class="stackedit__html">{{{files.0.content.html}}}</div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{files.0.name}}</title>
<link rel="stylesheet" href="https://stackedit.io/style.css" />
</head>
{{#if pdf}}
<body class="stackedit stackedit--pdf">
{{else}}
<body class="stackedit">
{{/if}}
<div class="stackedit__left">
<div class="stackedit__toc">
{{#tocToHtml files.0.content.toc 2}}{{/tocToHtml}}
</div>
</div>
<div class="stackedit__right">
<div class="stackedit__html">
{{{files.0.content.html}}}
</div>
</div>
</body>
</html>

View File

@ -1,196 +1,117 @@
# Welcome to StackEdit! # Welcome to StackEdit!
Hey! I'm your first Markdown document in **StackEdit**[^stackedit]. Don't delete me, I'm here to help! I can be recovered anyway in the **Utils** tab of the <i class="icon-cog"></i> **Settings** dialog. Hi! I'm your first Markdown file in **StackEdit**. If you want to learn about StackEdit, you can read me. If you want to play with Markdown, you can edit me. If you have finished with me, you can just create new files by opening the **file explorer** on left corner of the navigation bar.
## Documents # Files
StackEdit stores your documents in your browser, which means all your documents are automatically saved locally and are accessible **offline!** StackEdit stores your files in your browser, which means all your files are automatically saved locally and are accessible **offline!**
> **Note:** > **Note:**
>
> - StackEdit can be used offline thanks to the application cache.
> - Your local files are not shared between different browsers or computers unless you use the [synchronization mechanism](#synchronization).
> - Clearing your browser's data may **delete all your files!** Make sure you sign in with Google to have all your files and settings backed up and synced.
> - StackEdit is accessible offline after the application has been loaded for the first time. ## Create files and folders
> - Your local documents are not shared between different browsers or computers.
> - Clearing your browser's data may **delete all your local documents!** Make sure your documents are synchronized with **Google Drive** or **Dropbox** (check out the [<i class="icon-refresh"></i> Synchronization](#synchronization) section).
#### <i class="icon-file"></i> Create a document The file explorer is accessible using the button in left corner of the navigation bar. You can create a new file by clicking the **New file** button in the file explorer. You can also create folders by clicking the **New folder** button.
The document panel is accessible using the <i class="icon-folder-open"></i> button in the navigation bar. You can create a new document by clicking <i class="icon-file"></i> **New document** in the document panel. ## Switch to another file
#### <i class="icon-folder-open"></i> Switch to another document All your files are listed in the file explorer. You can switch from one to another by clicking a file in the list.
All your local documents are listed in the document panel. You can switch from one to another by clicking a document in the list or you can toggle documents using <kbd>Ctrl+[</kbd> and <kbd>Ctrl+]</kbd>. ## Rename a file
#### <i class="icon-pencil"></i> Rename a document You can rename the current file by clicking the file name in the navigation bar or by clicking the **Rename** button in the file explorer.
You can rename the current document by clicking the document title in the navigation bar. ## Delete a file
#### <i class="icon-trash"></i> Delete a document You can delete the current file by clicking the **Remove** button in the file explorer. The file will be moved into the **Trash** folder and automatically deleted after 7 days of inactivity.
You can delete the current document by clicking <i class="icon-trash"></i> **Delete document** in the document panel. ## Export a file
#### <i class="icon-hdd"></i> Export a document You can export the current file by clicking **Export to disk** in the menu. You can choose to export the file as plain Markdown, as HTML using a Handlebars template or as a PDF.
You can save the current document to a file by clicking <i class="icon-hdd"></i> **Export to disk** from the <i class="icon-provider-stackedit"></i> menu panel.
> **Tip:** Check out the [<i class="icon-upload"></i> Publish a document](#publish-a-document) section for a description of the different output formats. # Synchronization
Synchronization is one of the biggest features of StackEdit. It enables you to synchronize any file in your workspace with other files stored in your **Google Drive**, your **Dropbox** and your **GitHub** accounts. This allows you to keep writing on other devices, collaborate with people you share the file with, integrate easily into your workflow... The synchronization mechanism takes place every minute in the background, downloading, merging, and uploading file modifications.
## Synchronization There are two types of synchronization and they can complement each other:
StackEdit can be combined with <i class="icon-provider-gdrive"></i> **Google Drive** and <i class="icon-provider-dropbox"></i> **Dropbox** to have your documents saved in the *Cloud*. The synchronization mechanism takes care of uploading your modifications or downloading the latest version of your documents. - The workspace synchronization will sync all your files, folders and settings automatically. This will allow you to fetch your workspace on any other device.
> To start syncing your workspace, just sign in with Google in the menu.
> **Note:** - The file synchronization will keep one file of the workspace synced with one or multiple files in **Google Drive**, **Dropbox** or **GitHub**.
> Before starting to sync files, you must link an account in the **Synchronize** sub-menu.
> - Full access to **Google Drive** or **Dropbox** is required to be able to import any document in StackEdit. Permission restrictions can be configured in the settings. ## Open a file
> - Imported documents are downloaded in your browser and are not transmitted to a server.
> - If you experience problems saving your documents on Google Drive, check and optionally disable browser extensions, such as Disconnect.
#### <i class="icon-refresh"></i> Open a document You can open a file from **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Open from**. Once opened in the workspace, any modification in the file will be automatically synced.
You can open a document from <i class="icon-provider-gdrive"></i> **Google Drive** or the <i class="icon-provider-dropbox"></i> **Dropbox** by opening the <i class="icon-refresh"></i> **Synchronize** sub-menu and by clicking **Open from...**. Once opened, any modification in your document will be automatically synchronized with the file in your **Google Drive** / **Dropbox** account. ## Save a file
#### <i class="icon-refresh"></i> Save a document You can save any file of the workspace to **Google Drive**, **Dropbox** or **GitHub** by opening the **Synchronize** sub-menu and clicking **Save on**. Even if a file in the workspace is already synced, you can save it to another location. StackEdit can sync one file with multiple locations and accounts.
You can save any document by opening the <i class="icon-refresh"></i> **Synchronize** sub-menu and by clicking **Save on...**. Even if your document is already synchronized with **Google Drive** or **Dropbox**, you can export it to a another location. StackEdit can synchronize one document with multiple locations and accounts. ## Synchronize a file
#### <i class="icon-refresh"></i> Synchronize a document Once your file is linked to a synchronized location, StackEdit will periodically synchronize it by downloading/uploading any modification. A merge will be performed if necessary and conflicts will be resolved.
Once your document is linked to a <i class="icon-provider-gdrive"></i> **Google Drive** or a <i class="icon-provider-dropbox"></i> **Dropbox** file, StackEdit will periodically (every 3 minutes) synchronize it by downloading/uploading any modification. A merge will be performed if necessary and conflicts will be detected. If you just have modified your file and you want to force syncing, click the **Synchronize now** button in the navigation bar.
If you just have modified your document and you want to force the synchronization, click the <i class="icon-refresh"></i> button in the navigation bar. > **Note:** The **Synchronize now** button is disabled if you have no file to synchronize.
> **Note:** The <i class="icon-refresh"></i> button is disabled when you have no document to synchronize. ## Manage file synchronization
#### <i class="icon-refresh"></i> Manage document synchronization Since one file can be synced with multiple locations, you can list and manage synchronized locations by clicking **File synchronization** in the **Synchronize** sub-menu. This allows you to list and remove synchronized locations that are linked to your file.
Since one document can be synchronized with multiple locations, you can list and manage synchronized locations by clicking <i class="icon-refresh"></i> **Manage synchronization** in the <i class="icon-refresh"></i> **Synchronize** sub-menu. This will let you remove synchronization locations that are associated to your document.
> **Note:** If you delete the file from **Google Drive** or from **Dropbox**, the document will no longer be synchronized with that location. # Publication
Publishing in StackEdit makes it simple for you to publish online your files. Once you're happy with a file, you can publish it to different hosting platforms like **Blogger**, **Dropbox**, **Gist**, **GitHub**, **Google Drive**, **WordPress** and **Zendesk**. With [Handlebars templates](http://handlebarsjs.com/), you have full control over what you export.
## Publication > Before starting to publish, you must link an account in the **Publish** sub-menu.
Once you are happy with your document, you can publish it on different websites directly from StackEdit. As for now, StackEdit can publish on **Blogger**, **Dropbox**, **Gist**, **GitHub**, **Google Drive**, **Tumblr**, **WordPress** and on any SSH server. ## Publish a File
#### <i class="icon-upload"></i> Publish a document You can publish your file by opening the **Publish** sub-menu and by clicking **Publish to**. For some locations, you can choose between the following formats:
You can publish your document by opening the <i class="icon-upload"></i> **Publish** sub-menu and by choosing a website. In the dialog box, you can choose the publication format: - Markdown: publish the Markdown text on a website that can interpret it (**GitHub** for instance),
- HTML: publish the file converted to HTML via a Handlebars template (on a blog for example).
- Markdown, to publish the Markdown text on a website that can interpret it (**GitHub** for instance), ## Update a publication
- HTML, to publish the document converted into HTML (on a blog for example),
- Template, to have a full control of the output.
> **Note:** The default template is a simple webpage wrapping your document in HTML format. You can customize it in the **Advanced** tab of the <i class="icon-cog"></i> **Settings** dialog. After publishing, StackEdit keeps your file linked to that publication which makes it easy for you to re-publish it. Once you have modified your file and you want to update your publication, click on the **Publish now** button in the navigation bar.
#### <i class="icon-upload"></i> Update a publication > **Note:** The **Publish now** button is disabled if your file has not been published yet.
After publishing, StackEdit will keep your document linked to that publication which makes it easy for you to update it. Once you have modified your document and you want to update your publication, click on the <i class="icon-upload"></i> button in the navigation bar. ## Manage file publication
> **Note:** The <i class="icon-upload"></i> button is disabled when your document has not been published yet. Since one file can be published to multiple locations, you can list and manage publish locations by clicking **File publication** in the **Publish** sub-menu. This allows you to list and remove publication locations that are linked to your file.
#### <i class="icon-upload"></i> Manage document publication
Since one document can be published on multiple locations, you can list and manage publish locations by clicking <i class="icon-upload"></i> **Manage publication** in the <i class="icon-provider-stackedit"></i> menu panel. This will let you remove publication locations that are associated to your document. # Markdown extensions
> **Note:** If the file has been removed from the website or the blog, the document will no longer be published on that location. StackEdit extends the standard Markdown syntax by adding extra **Markdown extensions**, providing you with some nice features.
> **ProTip:** You can disable any **Markdown extension** in the **File properties** dialog.
## Markdown Extra
StackEdit supports **Markdown Extra**, which extends **Markdown** syntax with some nice features. ## SmartyPants
> **Tip:** You can disable any **Markdown Extra** feature in the **Extensions** tab of the <i class="icon-cog"></i> **Settings** dialog.
> **Note:** You can find more information about **Markdown** syntax [here][2] and **Markdown Extra** extension [here][3].
### Tables
**Markdown Extra** has a special syntax for tables:
Item | Value
-------- | ---
Computer | $1600
Phone | $12
Pipe | $1
You can specify column alignment with one or two colons:
| Item | Value | Qty |
| :------- | ----: | :---: |
| Computer | $1600 | 5 |
| Phone | $12 | 12 |
| Pipe | $1 | 234 |
### Definition Lists
**Markdown Extra** has a special syntax for definition lists too:
Term 1
Term 2
: Definition A
: Definition B
Term 3
: Definition C
: Definition D
> part of definition D
### Fenced code blocks
GitHub's fenced code blocks are also supported with **Highlight.js** syntax highlighting:
```
// Foo
var bar = 0;
```
```js
var foo = 'bar'; // baz
```
> **Tip:** To use **Prettify** instead of **Highlight.js**, just configure the **Markdown Extra** extension in the <i class="icon-cog"></i> **Settings** dialog.
> **Note:** You can find more information:
> - about **Prettify** syntax highlighting [here][5],
> - about **Highlight.js** syntax highlighting [here][6].
### Footnotes
You can create footnotes like this[^footnote].
[^footnote]: Here is the *text* of the **footnote**.
### SmartyPants
SmartyPants converts ASCII punctuation characters into "smart" typographic punctuation HTML entities. For example: SmartyPants converts ASCII punctuation characters into "smart" typographic punctuation HTML entities. For example:
| | ASCII | HTML | | |ASCII |HTML |
----------------- | ---------------------------- | ------------------ |----------------|-------------------------------|-----------------------------|
| Single backticks | `'Isn't this fun?'` | 'Isn't this fun?' | |Single backticks|`'Isn't this fun?'` |'Isn't this fun?' |
| Quotes | `"Isn't this fun?"` | "Isn't this fun?" | |Quotes |`"Isn't this fun?"` |"Isn't this fun?" |
| Dashes | `-- is en-dash, --- is em-dash` | -- is en-dash, --- is em-dash | |Dashes |`-- is en-dash, --- is em-dash`|-- is en-dash, --- is em-dash|
### Table of contents ## KaTeX
You can insert a table of contents using the marker `[TOC]`: You can render LaTeX mathematical expressions using [KaTeX](https://khan.github.io/KaTeX/):
[TOC]
### MathJax
You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com][1]:
The *Gamma function* satisfying $\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$ is via the Euler integral The *Gamma function* satisfying $\Gamma(n) = (n-1)!\quad\forall n\in\mathbb N$ is via the Euler integral
@ -198,55 +119,31 @@ $$
\Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. \Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,.
$$ $$
> **Tip:** To make sure mathematical expressions are rendered properly on your website, include **MathJax** into your template: > You can find more information about **LaTeX** mathematical expressions [here](http://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference).
```
<script type="text/javascript" src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"></script> ## UML diagrams
You can render UML diagrams using [Mermaid](https://mermaidjs.github.io/). For example, this will produce a sequence diagram:
```mermaid
sequenceDiagram
Alice ->> Bob: Hello Bob, how are you?
Bob-->>John: How about you John?
Bob--x Alice: I am good thanks!
Bob-x John: I am good thanks!
Note right of John: Bob thinks a long<br/>long time, so long<br/>that the text does<br/>not fit on a row.
Bob-->Alice: Checking with John...
Alice->John: Yes... John, how are you?
``` ```
> **Note:** You can find more information about **LaTeX** mathematical expressions [here][4]. And this will produce a flow chart:
```mermaid
### UML diagrams graph LR
A[Square Rect] -- Link text --> B((Circle))
You can also render sequence diagrams like this: A --> C(Round Rect)
B --> D{Rhombus}
```sequence C --> D
Alice->Bob: Hello Bob, how are you?
Note right of Bob: Bob thinks
Bob-->Alice: I am good thanks!
``` ```
And flow charts like this:
```flow
st=>start: Start
e=>end
op=>operation: My Operation
cond=>condition: Yes or No?
st->op->cond
cond(yes)->e
cond(no)->op
```
> **Note:** You can find more information:
> - about **Sequence diagrams** syntax [here][7],
> - about **Flow charts** syntax [here][8].
### Support StackEdit
[![](https://cdn.monetizejs.com/resources/button-32.png)](https://monetizejs.com/authorize?client_id=ESTHdCYOi18iLhhO&summary=true)
[^stackedit]: [StackEdit](https://stackedit.io/) is a full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites.
[1]: http://math.stackexchange.com/
[2]: http://daringfireball.net/projects/markdown/syntax "Markdown"
[3]: https://github.com/jmcmanus/pagedown-extra "Pagedown Extra"
[4]: http://meta.math.stackexchange.com/questions/5020/mathjax-basic-tutorial-and-quick-reference
[5]: https://code.google.com/p/google-code-prettify/
[6]: http://highlightjs.org/
[7]: http://bramp.github.io/js-sequence-diagrams/
[8]: http://adrai.github.io/flowchart.js/

View File

@ -0,0 +1,13 @@
import markdownItEmoji from 'markdown-it-emoji';
import extensionSvc from '../services/extensionSvc';
extensionSvc.onGetOptions((options, properties) => {
options.emoji = properties.extensions.emoji.enabled;
options.emojiShortcuts = properties.extensions.emoji.shortcuts;
});
extensionSvc.onInitConverter(1, (markdown, options) => {
if (options.emoji) {
markdown.use(markdownItEmoji, options.emojiShortcuts ? {} : { shortcuts: {} });
}
});

View File

@ -1,2 +1,4 @@
import './katexExt'; import './emojiExtension';
import './markdownExt'; import './katexExtension';
import './markdownExtension';
import './mermaidExtension';

View File

@ -178,9 +178,9 @@ extensionSvc.onInitConverter(0, (markdown, options) => {
extensionSvc.onSectionPreview((elt) => { extensionSvc.onSectionPreview((elt) => {
elt.querySelectorAll('.prism').cl_each((prismElt) => { elt.querySelectorAll('.prism').cl_each((prismElt) => {
if (!prismElt.highlighted) { if (!prismElt.highlightedWithPrism) {
Prism.highlightElement(prismElt); Prism.highlightElement(prismElt);
prismElt.highlightedWithPrism = true;
} }
prismElt.highlighted = true;
}); });
}); });

View File

@ -0,0 +1,122 @@
import mermaidUtils from 'mermaid/src/utils';
import flowRenderer from 'mermaid/src/diagrams/flowchart/flowRenderer';
import seq from 'mermaid/src/diagrams/sequenceDiagram/sequenceRenderer';
import info from 'mermaid/src/diagrams/example/exampleRenderer';
import gantt from 'mermaid/src/diagrams/gantt/ganttRenderer';
import classRenderer from 'mermaid/src/diagrams/classDiagram/classRenderer';
import gitGraphRenderer from 'mermaid/src/diagrams/gitGraph/gitGraphRenderer';
import extensionSvc from '../services/extensionSvc';
import utils from '../services/utils';
const config = {
logLevel: 5,
startOnLoad: false,
arrowMarkerAbsolute: false,
flowchart: {
htmlLabels: true,
useMaxWidth: true,
},
sequenceDiagram: {
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35,
mirrorActors: true,
bottomMarginAdj: 1,
useMaxWidth: true,
},
gantt: {
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
fontFamily: '"Open-Sans", "sans-serif"',
numberSectionStyles: 3,
axisFormatter: [
// Within a day
['%I:%M', d => d.getHours()],
// Monday a week
['w. %U', d => d.getDay() === 1],
// Day within a week (not monday)
['%a %d', d => d.getDay() && d.getDate() !== 1],
// within a month
['%b %d', d => d.getDate() !== 1],
// Month
['%m-%y', d => d.getMonth()],
],
},
classDiagram: {},
gitGraph: {},
info: {},
};
const containerElt = document.createElement('div');
containerElt.className = 'hidden-rendering-container';
document.body.appendChild(containerElt);
const render = (elt) => {
const svgId = `mermaid-svg-${utils.uid()}`;
const txt = elt.textContent;
containerElt.innerHTML = `<div class="mermaid"><svg xmlns="http://www.w3.org/2000/svg" id="${svgId}"><g></g></svg></div>`;
try {
const graphType = mermaidUtils.detectType(txt);
switch (graphType) {
case 'gitGraph':
config.flowchart.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
gitGraphRenderer.setConf(config.gitGraph);
gitGraphRenderer.draw(txt, svgId, false);
break;
case 'graph':
config.flowchart.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
flowRenderer.setConf(config.flowchart);
flowRenderer.draw(txt, svgId, false);
break;
case 'dotGraph':
config.flowchart.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
flowRenderer.setConf(config.flowchart);
flowRenderer.draw(txt, svgId, true);
break;
case 'sequenceDiagram':
config.sequenceDiagram.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
seq.setConf(config.sequenceDiagram);
seq.draw(txt, svgId);
break;
case 'gantt':
config.gantt.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
gantt.setConf(config.gantt);
gantt.draw(txt, svgId);
break;
case 'classDiagram':
config.classDiagram.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
classRenderer.setConf(config.classDiagram);
classRenderer.draw(txt, svgId);
break;
case 'info':
default:
config.info.arrowMarkerAbsolute = config.arrowMarkerAbsolute;
info.draw(txt, svgId, 'Unknown');
break;
}
elt.parentNode.replaceChild(containerElt.firstChild, elt);
} catch (e) {
console.error(e); // eslint-disable-line no-console
}
};
extensionSvc.onGetOptions((options, properties) => {
options.mermaid = properties.extensions.mermaid.enabled;
});
extensionSvc.onSectionPreview((elt) => {
elt.querySelectorAll('.prism.language-mermaid').cl_each(
diagramElt => render(diagramElt.parentNode));
});

5
src/icons/File.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2H6Z" />
</svg>
</template>

View File

@ -40,6 +40,7 @@ import Information from './Information';
import Alert from './Alert'; import Alert from './Alert';
import SignalOff from './SignalOff'; import SignalOff from './SignalOff';
import Folder from './Folder'; import Folder from './Folder';
import File from './File';
import ScrollSync from './ScrollSync'; import ScrollSync from './ScrollSync';
import Printer from './Printer'; import Printer from './Printer';
import Undo from './Undo'; import Undo from './Undo';
@ -87,6 +88,7 @@ Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert); Vue.component('iconAlert', Alert);
Vue.component('iconSignalOff', SignalOff); Vue.component('iconSignalOff', SignalOff);
Vue.component('iconFolder', Folder); Vue.component('iconFolder', Folder);
Vue.component('iconFile', File);
Vue.component('iconScrollSync', ScrollSync); Vue.component('iconScrollSync', ScrollSync);
Vue.component('iconPrinter', Printer); Vue.component('iconPrinter', Printer);
Vue.component('iconUndo', Undo); Vue.component('iconUndo', Undo);

View File

@ -1,4 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim';
import * as OfflinePluginRuntime from 'offline-plugin/runtime'; import * as OfflinePluginRuntime from 'offline-plugin/runtime';
import './extensions/'; import './extensions/';
import './services/optional'; import './services/optional';
@ -7,6 +9,10 @@ import App from './components/App';
import store from './store'; import store from './store';
import localDbSvc from './services/localDbSvc'; import localDbSvc from './services/localDbSvc';
if (!indexedDB) {
throw new Error('Your browser is not supported. Please upgrade to the latest version.');
}
if (NODE_ENV === 'production') { if (NODE_ENV === 'production') {
OfflinePluginRuntime.install({ OfflinePluginRuntime.install({
onUpdateReady: () => { onUpdateReady: () => {

View File

@ -103,12 +103,13 @@ function cledit(contentElt, scrollElt, windowParam) {
selectionMgr.updateCursorCoordinates(true) selectionMgr.updateCursorCoordinates(true)
} }
function replaceAll(search, replacement) { function replaceAll(search, replacement, startOffset = 0) {
undoMgr.setDefaultMode('single') undoMgr.setDefaultMode('single')
var textContent = getTextContent() var text = getTextContent()
var value = textContent.replace(search, replacement) var subtext = getTextContent().slice(startOffset);
if (value !== textContent) { var value = subtext.replace(search, replacement)
var offset = editor.setContent(value) if (value !== subtext) {
var offset = editor.setContent(text.slice(0, startOffset) + value);
selectionMgr.setSelectionStartEnd(offset.end, offset.end) selectionMgr.setSelectionStartEnd(offset.end, offset.end)
selectionMgr.updateCursorCoordinates(true) selectionMgr.updateCursorCoordinates(true)
} }
@ -130,7 +131,7 @@ function cledit(contentElt, scrollElt, windowParam) {
var triggerSpellCheck = debounce(function () { var triggerSpellCheck = debounce(function () {
var selection = editor.$window.getSelection() var selection = editor.$window.getSelection()
if (!selectionMgr.hasFocus || highlighter.isComposing || selectionMgr.selectionStart !== selectionMgr.selectionEnd || !selection.modify) { if (!selectionMgr.hasFocus() || highlighter.isComposing || selectionMgr.selectionStart !== selectionMgr.selectionEnd || !selection.modify) {
return return
} }
// Hack for Chrome to trigger the spell checker // Hack for Chrome to trigger the spell checker
@ -209,7 +210,8 @@ function cledit(contentElt, scrollElt, windowParam) {
if (!editor.$window.document.contains(contentElt)) { if (!editor.$window.document.contains(contentElt)) {
watcher.stopWatching() watcher.stopWatching()
editor.$window.removeEventListener('keydown', windowKeydownListener) editor.$window.removeEventListener('keydown', windowKeydownListener)
editor.$window.removeEventListener('mouseup', windowMouseupListener) editor.$window.removeEventListener('mousedown', windowMouseListener)
editor.$window.removeEventListener('mouseup', windowMouseListener)
editor.$trigger('destroy') editor.$trigger('destroy')
return true return true
} }
@ -226,12 +228,13 @@ function cledit(contentElt, scrollElt, windowParam) {
editor.$window.addEventListener('keydown', windowKeydownListener, false) editor.$window.addEventListener('keydown', windowKeydownListener, false)
// Mouseup can happen outside the editor element // Mouseup can happen outside the editor element
function windowMouseupListener() { function windowMouseListener() {
if (!tryDestroy()) { if (!tryDestroy()) {
selectionMgr.saveSelectionState(true, false) selectionMgr.saveSelectionState(true, false)
} }
} }
editor.$window.addEventListener('mouseup', windowMouseupListener) editor.$window.addEventListener('mousedown', windowMouseListener)
editor.$window.addEventListener('mouseup', windowMouseListener)
// This can also provoke selection changes and does not fire mouseup event on Chrome/OSX // This can also provoke selection changes and does not fire mouseup event on Chrome/OSX
contentElt.addEventListener('contextmenu', selectionMgr.saveSelectionState.cl_bind(selectionMgr, true, false)) contentElt.addEventListener('contextmenu', selectionMgr.saveSelectionState.cl_bind(selectionMgr, true, false))
@ -297,12 +300,10 @@ function cledit(contentElt, scrollElt, windowParam) {
}, false) }, false)
contentElt.addEventListener('focus', function () { contentElt.addEventListener('focus', function () {
selectionMgr.hasFocus = true
editor.$trigger('focus') editor.$trigger('focus')
}, false) }, false)
contentElt.addEventListener('blur', function () { contentElt.addEventListener('blur', function () {
selectionMgr.hasFocus = false
editor.$trigger('blur') editor.$trigger('blur')
}, false) }, false)

View File

@ -49,12 +49,16 @@ function SelectionMgr(editor) {
this.$trigger('cursorCoordinatesChanged', coordinates) this.$trigger('cursorCoordinatesChanged', coordinates)
} }
if (adjustScroll) { if (adjustScroll) {
var adjustment = scrollElt.clientHeight / 2 * editor.options.getCursorFocusRatio() var scrollEltHeight = scrollElt.clientHeight;
if (typeof adjustScroll === 'number') {
scrollEltHeight -= adjustScroll
}
var adjustment = scrollEltHeight / 2 * editor.options.getCursorFocusRatio()
var cursorTop = this.cursorCoordinates.top + this.cursorCoordinates.height / 2 var cursorTop = this.cursorCoordinates.top + this.cursorCoordinates.height / 2
// Adjust cursorTop with contentElt position relative to scrollElt // Adjust cursorTop with contentElt position relative to scrollElt
cursorTop += contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top + scrollElt.scrollTop; cursorTop += contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top + scrollElt.scrollTop;
var minScrollTop = cursorTop - adjustment var minScrollTop = cursorTop - adjustment
var maxScrollTop = cursorTop + adjustment - scrollElt.clientHeight var maxScrollTop = cursorTop + adjustment - scrollEltHeight
if (scrollElt.scrollTop > minScrollTop) { if (scrollElt.scrollTop > minScrollTop) {
scrollElt.scrollTop = minScrollTop scrollElt.scrollTop = minScrollTop
} else if (scrollElt.scrollTop < maxScrollTop) { } else if (scrollElt.scrollTop < maxScrollTop) {
@ -84,6 +88,10 @@ function SelectionMgr(editor) {
} }
} }
this.hasFocus = function() {
return contentElt === editor.$document.activeElement;
}
this.restoreSelection = function () { this.restoreSelection = function () {
var min = Math.min(this.selectionStart, this.selectionEnd) var min = Math.min(this.selectionStart, this.selectionEnd)
var max = Math.max(this.selectionStart, this.selectionEnd) var max = Math.max(this.selectionStart, this.selectionEnd)
@ -132,9 +140,9 @@ function SelectionMgr(editor) {
saveLastSelection() saveLastSelection()
} }
this.setSelectionStartEnd = function (start, end, focus) { this.setSelectionStartEnd = function (start, end) {
setSelection(start, end) setSelection(start, end)
return focus !== false && this.restoreSelection() return this.hasFocus() && this.restoreSelection()
} }
this.saveSelectionState = (function () { this.saveSelectionState = (function () {
@ -230,48 +238,50 @@ function SelectionMgr(editor) {
} }
function save() { function save() {
var selectionStart = self.selectionStart
var selectionEnd = self.selectionEnd
var selection = editor.$window.getSelection()
var result var result
if (selection.rangeCount > 0) { if (self.hasFocus()) {
var selectionRange = selection.getRangeAt(0) var selectionStart = self.selectionStart
var node = selectionRange.startContainer var selectionEnd = self.selectionEnd
if ((contentElt.compareDocumentPosition(node) & window.Node.DOCUMENT_POSITION_CONTAINED_BY) || contentElt === node) { var selection = editor.$window.getSelection()
var offset = selectionRange.startOffset if (selection.rangeCount > 0) {
if (node.firstChild && offset > 0) { var selectionRange = selection.getRangeAt(0)
node = node.childNodes[offset - 1] var node = selectionRange.startContainer
offset = node.textContent.length if ((contentElt.compareDocumentPosition(node) & window.Node.DOCUMENT_POSITION_CONTAINED_BY) || contentElt === node) {
} var offset = selectionRange.startOffset
var container = node if (node.firstChild && offset > 0) {
while (node !== contentElt) { node = node.childNodes[offset - 1]
while ((node = node.previousSibling)) { offset = node.textContent.length
offset += (node.textContent || '').length }
var container = node
while (node !== contentElt) {
while ((node = node.previousSibling)) {
offset += (node.textContent || '').length
}
node = container = container.parentNode
}
var selectionText = selectionRange + ''
// Fix end of line when only br is selected
var brElt = selectionRange.endContainer.firstChild
if (brElt && brElt.tagName === 'BR' && selectionRange.endOffset === 1) {
selectionText += '\n'
}
if (comparePoints(selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset) === 1) {
selectionStart = offset + selectionText.length
selectionEnd = offset
} else {
selectionStart = offset
selectionEnd = offset + selectionText.length
} }
node = container = container.parentNode
}
var selectionText = selectionRange + ''
// Fix end of line when only br is selected
var brElt = selectionRange.endContainer.firstChild
if (brElt && brElt.tagName === 'BR' && selectionRange.endOffset === 1) {
selectionText += '\n'
}
if (comparePoints(selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset) === 1) {
selectionStart = offset + selectionText.length
selectionEnd = offset
} else {
selectionStart = offset
selectionEnd = offset + selectionText.length
}
if (selectionStart === selectionEnd && selectionStart === editor.getContent().length) { if (selectionStart === selectionEnd && selectionStart === editor.getContent().length) {
// If cursor is after the trailingNode // If cursor is after the trailingNode
selectionStart = --selectionEnd selectionStart = --selectionEnd
result = self.setSelectionStartEnd(selectionStart, selectionEnd) result = self.setSelectionStartEnd(selectionStart, selectionEnd)
} else { } else {
setSelection(selectionStart, selectionEnd) setSelection(selectionStart, selectionEnd)
result = checkSelection(selectionRange) result = checkSelection(selectionRange)
result = result || lastSelectionStart !== self.selectionStart // selectionRange doesn't change when selection is at the start of a section result = result || lastSelectionStart !== self.selectionStart // selectionRange doesn't change when selection is at the start of a section
}
} }
} }
} }

View File

@ -103,7 +103,7 @@ function mergeText(serverText, clientText, lastMergedText) {
const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText) const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText)
// Keep only equalities and deletions // Keep only equalities and deletions
.filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT); .filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT);
diffMatchPatch.diff_cleanupSemantic(serverClientDiffs); diffMatchPatch.diff_cleanupSemantic(lastMergedTextDiffs);
// Make a patch with deletions only // Make a patch with deletions only
const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs); const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs);
// Apply patch to fusion text // Apply patch to fusion text

View File

@ -131,24 +131,25 @@ export default {
initClEditor(opts) { initClEditor(opts) {
const content = store.getters['content/current']; const content = store.getters['content/current'];
if (content) { if (content) {
const options = Object.assign({}, opts); const contentState = store.getters['contentState/current'];
const options = Object.assign({
selectionStart: contentState.selectionStart,
selectionEnd: contentState.selectionEnd,
patchHandler: {
makePatches,
applyPatches,
reversePatches,
},
}, opts);
if (contentId !== content.id) { if (contentId !== content.id) {
contentId = content.id; contentId = content.id;
currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap); currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap);
previousPatchableText = currentPatchableText; previousPatchableText = currentPatchableText;
syncDiscussionMarkers(content, false); syncDiscussionMarkers(content, false);
const contentState = store.getters['contentState/current'];
options.content = content.text; options.content = content.text;
options.selectionStart = contentState.selectionStart;
options.selectionEnd = contentState.selectionEnd;
} }
options.patchHandler = {
makePatches,
applyPatches,
reversePatches,
};
clEditor.init(options); clEditor.init(options);
} }
}, },

View File

@ -208,6 +208,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
let insertBeforePreviewElt = this.previewElt.firstChild; let insertBeforePreviewElt = this.previewElt.firstChild;
let insertBeforeTocElt = this.tocElt.firstChild; let insertBeforeTocElt = this.tocElt.firstChild;
let previewHtml = ''; let previewHtml = '';
let loadingImages = [];
this.conversionCtx.htmlSectionDiff.forEach((item) => { this.conversionCtx.htmlSectionDiff.forEach((item) => {
for (let i = 0; i < item[1].length; i += 1) { for (let i = 0; i < item[1].length; i += 1) {
const section = this.conversionCtx.sectionList[sectionIdx]; const section = this.conversionCtx.sectionList[sectionIdx];
@ -218,9 +219,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
newSectionDescList.push(sectionDesc); newSectionDescList.push(sectionDesc);
previewHtml += sectionDesc.html; previewHtml += sectionDesc.html;
sectionIdx += 1; sectionIdx += 1;
insertBeforePreviewElt.classList.remove('modified');
insertBeforePreviewElt = insertBeforePreviewElt.nextSibling; insertBeforePreviewElt = insertBeforePreviewElt.nextSibling;
insertBeforeTocElt.classList.remove('modified');
insertBeforeTocElt = insertBeforeTocElt.nextSibling; insertBeforeTocElt = insertBeforeTocElt.nextSibling;
} else if (item[0] === -1) { } else if (item[0] === -1) {
sectionDescIdx += 1; sectionDescIdx += 1;
@ -236,7 +235,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// Create preview section element // Create preview section element
sectionPreviewElt = document.createElement('div'); sectionPreviewElt = document.createElement('div');
sectionPreviewElt.className = 'cl-preview-section modified'; sectionPreviewElt.className = 'cl-preview-section';
sectionPreviewElt.innerHTML = html; sectionPreviewElt.innerHTML = html;
if (insertBeforePreviewElt) { if (insertBeforePreviewElt) {
this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt); this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt);
@ -244,10 +243,14 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.previewElt.appendChild(sectionPreviewElt); this.previewElt.appendChild(sectionPreviewElt);
} }
extensionSvc.sectionPreview(sectionPreviewElt, this.options); extensionSvc.sectionPreview(sectionPreviewElt, this.options);
loadingImages = [
...loadingImages,
...Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('img')),
];
// Create TOC section element // Create TOC section element
sectionTocElt = document.createElement('div'); sectionTocElt = document.createElement('div');
sectionTocElt.className = 'cl-toc-section modified'; sectionTocElt.className = 'cl-toc-section';
const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6'); const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6');
if (headingElt) { if (headingElt) {
const clonedElt = headingElt.cloneNode(true); const clonedElt = headingElt.cloneNode(true);
@ -278,8 +281,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
]('toc-tab--empty'); ]('toc-tab--empty');
// Run preview async operations (image loading, mathjax...) // Run preview async operations (image loading, mathjax...)
const loadingImages = this.previewElt.querySelectorAll('.cl-preview-section.modified img'); const loadedPromises = loadingImages.map(imgElt => new Promise((resolve) => {
const loadedPromises = loadingImages.cl_map(imgElt => new Promise((resolve) => {
if (!imgElt.src) { if (!imgElt.src) {
resolve(); resolve();
return; return;
@ -392,7 +394,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset); const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset);
if (editorStartOffset !== undefined && editorEndOffset !== undefined) { if (editorStartOffset !== undefined && editorEndOffset !== undefined) {
editorEngineSvc.clEditor.selectionMgr.setSelectionStartEnd( editorEngineSvc.clEditor.selectionMgr.setSelectionStartEnd(
editorStartOffset, editorEndOffset, false); editorStartOffset, editorEndOffset);
} }
} }
editorSvc.previewSelectionRange = range; editorSvc.previewSelectionRange = range;

View File

@ -34,6 +34,10 @@ function groupHeadings(headings, level = 1) {
return result; return result;
} }
const containerElt = document.createElement('div');
containerElt.className = 'hidden-rendering-container';
document.body.appendChild(containerElt);
export default { export default {
/** /**
* Apply the template to the file content * Apply the template to the file content
@ -41,7 +45,7 @@ export default {
applyTemplate(fileId, template = { applyTemplate(fileId, template = {
value: '{{{files.0.content.text}}}', value: '{{{files.0.content.text}}}',
helpers: '', helpers: '',
}) { }, pdf = false) {
const file = store.state.file.itemMap[fileId]; const file = store.state.file.itemMap[fileId];
return localDbSvc.loadItem(`${fileId}/content`) return localDbSvc.loadItem(`${fileId}/content`)
.then((content) => { .then((content) => {
@ -51,19 +55,19 @@ export default {
const parsingCtx = markdownConversionSvc.parseSections(converter, content.text); const parsingCtx = markdownConversionSvc.parseSections(converter, content.text);
const conversionCtx = markdownConversionSvc.convert(parsingCtx); const conversionCtx = markdownConversionSvc.convert(parsingCtx);
const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join(''); const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join('');
const elt = document.createElement('div'); containerElt.innerHTML = html;
elt.innerHTML = html; extensionSvc.sectionPreview(containerElt, options);
// Unwrap tables // Unwrap tables
elt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => { containerElt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => {
while (wrapperElt.firstChild) { while (wrapperElt.firstChild) {
wrapperElt.parentNode.appendChild(wrapperElt.firstChild); wrapperElt.parentNode.insertBefore(wrapperElt.firstChild, wrapperElt.nextSibling);
} }
wrapperElt.parentNode.removeChild(wrapperElt); wrapperElt.parentNode.removeChild(wrapperElt);
}); });
// Make TOC // Make TOC
const headings = elt.querySelectorAll('h1,h2,h3,h4,h5,h6').cl_map(headingElt => ({ const headings = containerElt.querySelectorAll('h1,h2,h3,h4,h5,h6').cl_map(headingElt => ({
title: headingElt.textContent, title: headingElt.textContent,
anchor: headingElt.id, anchor: headingElt.id,
level: parseInt(headingElt.tagName.slice(1), 10), level: parseInt(headingElt.tagName.slice(1), 10),
@ -71,17 +75,19 @@ export default {
})); }));
const toc = groupHeadings(headings); const toc = groupHeadings(headings);
const view = { const view = {
pdf,
files: [{ files: [{
name: file.name, name: file.name,
content: { content: {
text: content.text, text: content.text,
properties, properties,
yamlProperties: content.properties, yamlProperties: content.properties,
html: elt.innerHTML, html: containerElt.innerHTML,
toc, toc,
}, },
}], }],
}; };
containerElt.innerHTML = '';
// Run template conversion in a Worker to prevent attacks from helpers // Run template conversion in a Worker to prevent attacks from helpers
const worker = new TemplateWorker(); const worker = new TemplateWorker();
@ -111,8 +117,8 @@ export default {
exportToDisk(fileId, type, template) { exportToDisk(fileId, type, template) {
const file = store.state.file.itemMap[fileId]; const file = store.state.file.itemMap[fileId];
return this.applyTemplate(fileId, template) return this.applyTemplate(fileId, template)
.then((res) => { .then((html) => {
const blob = new Blob([res], { const blob = new Blob([html], {
type: 'text/plain;charset=utf-8', type: 'text/plain;charset=utf-8',
}); });
FileSaver.saveAs(blob, `${file.name}.${type}`); FileSaver.saveAs(blob, `${file.name}.${type}`);

View File

@ -1,21 +1,18 @@
import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';
import welcomeFile from '../data/welcomeFile.md'; import welcomeFile from '../data/welcomeFile.md';
const indexedDB = window.indexedDB;
const dbVersion = 1; const dbVersion = 1;
const dbVersionKey = `${utils.workspaceId}/localDbVersion`; const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup; const exportBackup = utils.queryParams.exportBackup;
if (exportBackup) {
if (!indexedDB) { location.hash = '';
throw new Error('Your browser is not supported. Please upgrade to the latest version.');
} }
const deleteMarkerMaxAge = 1000; const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
class Connection { class Connection {
constructor() { constructor() {
@ -328,6 +325,15 @@ localDbSvc.sync()
// Set the ready flag // Set the ready flag
store.commit('setReady'); store.commit('setReady');
// Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile);
const welcomeFileHashes = store.getters['data/welcomeFileHashes'];
if (!welcomeFileHashes[hash]) {
store.dispatch('data/patchWelcomeFileHashes', {
[hash]: 1,
});
}
// If app was last opened 7 days ago and synchronization is off // If app was last opened 7 days ago and synchronization is off
if (!store.getters['data/loginToken'] && if (!store.getters['data/loginToken'] &&
(utils.lastOpened + utils.cleanTrashAfter < Date.now()) (utils.lastOpened + utils.cleanTrashAfter < Date.now())
@ -338,6 +344,21 @@ localDbSvc.sync()
.forEach(file => store.dispatch('deleteFile', file.id)); .forEach(file => store.dispatch('deleteFile', file.id));
} }
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
location.hash = '';
store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken'];
// Force check sponsorship after a few seconds
const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', {
...loginToken,
expiresOn: currentDate - checkSponsorshipAfter,
});
}
}
// watch file changing // watch file changing
store.watch( store.watch(
() => store.getters['file/current'].id, () => store.getters['file/current'].id,

View File

@ -93,6 +93,7 @@ export default {
resolve({ resolve({
accessToken: data.access_token, accessToken: data.access_token,
code: data.code, code: data.code,
idToken: data.id_token,
expiresIn: data.expires_in, expiresIn: data.expires_in,
}); });
} }
@ -107,7 +108,7 @@ export default {
}, },
request(configParam, offlineCheck = false) { request(configParam, offlineCheck = false) {
let retryAfter = 500; // 500 ms let retryAfter = 500; // 500 ms
const maxRetryAfter = 30 * 1000; // 30 sec const maxRetryAfter = 10 * 1000; // 10 sec
const config = Object.assign({}, configParam); const config = Object.assign({}, configParam);
config.timeout = config.timeout || networkTimeout; config.timeout = config.timeout || networkTimeout;
config.headers = Object.assign({}, config.headers); config.headers = Object.assign({}, config.headers);
@ -151,9 +152,9 @@ export default {
const result = { const result = {
status: xhr.status, status: xhr.status,
headers: parseHeaders(xhr), headers: parseHeaders(xhr),
body: xhr.responseText, body: config.blob ? xhr.response : xhr.responseText,
}; };
if (!config.raw) { if (!config.raw && !config.blob) {
try { try {
result.body = JSON.parse(result.body); result.body = JSON.parse(result.body);
} catch (e) { } catch (e) {
@ -197,6 +198,9 @@ export default {
xhr.setRequestHeader(key, `${value}`); xhr.setRequestHeader(key, `${value}`);
} }
}); });
if (config.blob) {
xhr.responseType = 'blob';
}
xhr.send(config.body || null); xhr.send(config.body || null);
}) })
.catch((err) => { .catch((err) => {
@ -227,17 +231,18 @@ function checkOffline() {
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement('script');
let timeout; let timeout;
const cleaner = (cb, res) => () => { let clean = (cb) => {
clearTimeout(timeout); clearTimeout(timeout);
cb(res);
document.head.removeChild(script); document.head.removeChild(script);
clean = () => {}; // Prevent from cleaning several times
cb();
}; };
script.onload = cleaner(resolve); script.onload = () => clean(resolve);
script.onerror = cleaner(reject); script.onerror = () => clean(reject);
script.src = `https://apis.google.com/js/api.js?${Date.now()}`; script.src = `https://apis.google.com/js/api.js?${Date.now()}`;
try { try {
document.head.appendChild(script); // This can fail with bad network document.head.appendChild(script); // This can fail with bad network
timeout = setTimeout(cleaner(reject), networkTimeout); timeout = setTimeout(() => clean(reject), networkTimeout);
} catch (e) { } catch (e) {
reject(e); reject(e);
} }

View File

@ -13,6 +13,15 @@ const pagedownHandler = name => () => {
return true; return true;
}; };
const findReplaceOpener = type => () => {
store.dispatch('findReplace/open', {
type,
findText: editorEngineSvc.clEditor.selectionMgr.hasFocus() &&
editorEngineSvc.clEditor.selectionMgr.getSelectedText(),
});
return true;
};
const methods = { const methods = {
bold: pagedownHandler('bold'), bold: pagedownHandler('bold'),
italic: pagedownHandler('italic'), italic: pagedownHandler('italic'),
@ -30,13 +39,15 @@ const methods = {
} }
return true; return true;
}, },
find: findReplaceOpener('find'),
replace: findReplaceOpener('replace'),
expand(param1, param2) { expand(param1, param2) {
const text = `${param1 || ''}`; const text = `${param1 || ''}`;
const replacement = `${param2 || ''}`; const replacement = `${param2 || ''}`;
if (text && replacement) { if (text && replacement) {
setTimeout(() => { setTimeout(() => {
const selectionMgr = editorEngineSvc.clEditor.selectionMgr; const selectionMgr = editorEngineSvc.clEditor.selectionMgr;
let offset = editorEngineSvc.clEditor.selectionMgr.selectionStart; let offset = selectionMgr.selectionStart;
if (offset === selectionMgr.selectionEnd) { if (offset === selectionMgr.selectionEnd) {
const range = selectionMgr.createRange(offset - text.length, offset); const range = selectionMgr.createRange(offset - text.length, offset);
if (`${range}` === text) { if (`${range}` === text) {

View File

@ -2,6 +2,7 @@ import store from '../../store';
import githubHelper from './helpers/githubHelper'; import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils'; import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry'; import providerRegistry from './providerRegistry';
import utils from '../utils';
const savedSha = {}; const savedSha = {};
@ -65,6 +66,55 @@ export default providerRegistry.register({
}) })
.then(() => publishLocation); .then(() => publishLocation);
}, },
openFile(token, syncLocation) {
return Promise.resolve()
.then(() => {
if (providerUtils.openFileWithLocation(store.getters['syncLocation/items'], syncLocation)) {
// File exists and has just been opened. Next...
return null;
}
// Download content from GitHub and create the file
return this.downloadContent(token, syncLocation)
.then((content) => {
const id = utils.uid();
delete content.history;
store.commit('content/setItem', {
...content,
id: `${id}/content`,
});
let name = syncLocation.path;
const slashPos = name.lastIndexOf('/');
if (slashPos > -1 && slashPos < name.length - 1) {
name = name.slice(slashPos + 1);
}
const dotPos = name.lastIndexOf('.');
if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos);
}
store.commit('file/setItem', {
id,
name: utils.sanitizeName(name),
parentId: store.getters['file/current'].parentId,
});
store.commit('syncLocation/setItem', {
...syncLocation,
id: utils.uid(),
fileId: id,
});
store.commit('file/setCurrentId', id);
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`);
}, () => {
store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`);
});
});
},
parseRepoUrl(url) {
const parsedRepo = url.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && {
owner: parsedRepo[1],
repo: parsedRepo[2],
};
},
makeLocation(token, owner, repo, branch, path) { makeLocation(token, owner, repo, branch, path) {
return { return {
providerId: this.id, providerId: this.id,

View File

@ -19,6 +19,16 @@ const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker']; const libraries = ['picker'];
const checkIdToken = (idToken) => {
try {
const token = idToken.split('.');
const payload = JSON.parse(utils.decodeBase64(token[1]));
return payload.aud === clientId && Date.now() + tokenExpirationMargin < payload.exp * 1000;
} catch (e) {
return false;
}
};
export default { export default {
request(token, options) { request(token, options) {
return networkSvc.request({ return networkSvc.request({
@ -37,7 +47,7 @@ export default {
expiresOn: 0, expiresOn: 0,
}); });
// Refresh token and retry // Refresh token and retry
return this.refreshToken(token.scopes, token) return this.refreshToken(token, token.scopes)
.then(refreshedToken => this.request(refreshedToken, options)); .then(refreshedToken => this.request(refreshedToken, options));
} }
throw err; throw err;
@ -115,11 +125,12 @@ export default {
return networkSvc.startOauth2( return networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', { 'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId, client_id: clientId,
response_type: 'token', response_type: 'token id_token',
scope: ['openid', ...scopes].join(' '), // Need openid for user info scope: ['openid', ...scopes].join(' '),
hd: appsDomain, hd: appsDomain,
login_hint: sub, login_hint: sub,
prompt: silent ? 'none' : null, prompt: silent ? 'none' : null,
nonce: utils.uid(),
}, silent) }, silent)
// Call the token info endpoint // Call the token info endpoint
.then(data => networkSvc.request({ .then(data => networkSvc.request({
@ -142,9 +153,11 @@ export default {
scopes, scopes,
accessToken: data.accessToken, accessToken: data.accessToken,
expiresOn: Date.now() + (data.expiresIn * 1000), expiresOn: Date.now() + (data.expiresIn * 1000),
idToken: data.idToken,
sub: `${res.body.sub}`, sub: `${res.body.sub}`,
isLogin: !store.getters['data/loginToken'] && isLogin: !store.getters['data/loginToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1, scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isSponsor: false,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 || isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1, scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1,
isBlogger: scopes.indexOf('https://www.googleapis.com/auth/blogger') !== -1, isBlogger: scopes.indexOf('https://www.googleapis.com/auth/blogger') !== -1,
@ -163,19 +176,33 @@ export default {
// That's no problem, token will be refreshed later with merged scopes. // That's no problem, token will be refreshed later with merged scopes.
// Save flags // Save flags
token.isLogin = existingToken.isLogin || token.isLogin; token.isLogin = existingToken.isLogin || token.isLogin;
token.isSponsor = existingToken.isSponsor;
token.isDrive = existingToken.isDrive || token.isDrive; token.isDrive = existingToken.isDrive || token.isDrive;
token.isBlogger = existingToken.isBlogger || token.isBlogger; token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isPhotos = existingToken.isPhotos || token.isPhotos; token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
// Save nextPageToken
token.nextPageToken = existingToken.nextPageToken; token.nextPageToken = existingToken.nextPageToken;
} }
return token.isLogin && networkSvc.request({
method: 'GET',
url: 'userInfo',
params: {
idToken: token.idToken,
},
})
.then((res) => {
token.isSponsor = res.body.sponsorUntil > Date.now();
}, () => {
// Ignore error
});
})
.then(() => {
// Add token to googleTokens // Add token to googleTokens
store.dispatch('data/setGoogleToken', token); store.dispatch('data/setGoogleToken', token);
return token; return token;
})); }));
}, },
refreshToken(scopes, token) { refreshToken(token, scopes = []) {
const sub = token.sub; const sub = token.sub;
const lastToken = store.getters['data/googleTokens'][sub]; const lastToken = store.getters['data/googleTokens'][sub];
const mergedScopes = [...new Set([ const mergedScopes = [...new Set([
@ -185,8 +212,13 @@ export default {
return Promise.resolve() return Promise.resolve()
.then(() => { .then(() => {
if (mergedScopes.length === lastToken.scopes.length && if (
lastToken.expiresOn > Date.now() + tokenExpirationMargin // If we already have permissions for the requested scopes
mergedScopes.length === lastToken.scopes.length &&
// And lastToken is not expired
lastToken.expiresOn > Date.now() + tokenExpirationMargin &&
// And in case of a login token, ID token is still valid
(!lastToken.isLogin || checkIdToken(lastToken.idToken))
) { ) {
return lastToken; return lastToken;
} }
@ -222,6 +254,16 @@ export default {
google = window.google; google = window.google;
}); });
}, },
getSponsorship(token) {
return this.refreshToken(token)
.then(refreshedToken => networkSvc.request({
method: 'GET',
url: 'userInfo',
params: {
idToken: refreshedToken.idToken,
},
}, true));
},
signin() { signin() {
return this.startOauth2(driveAppDataScopes); return this.startOauth2(driveAppDataScopes);
}, },
@ -238,7 +280,7 @@ export default {
const result = { const result = {
changes: [], changes: [],
}; };
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(token, driveAppDataScopes)
.then((refreshedToken) => { .then((refreshedToken) => {
const getPage = (pageToken = '1') => this.request(refreshedToken, { const getPage = (pageToken = '1') => this.request(refreshedToken, {
method: 'GET', method: 'GET',
@ -262,25 +304,25 @@ export default {
}); });
}, },
uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) { uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.uploadFileInternal( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate)); refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
}, },
uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.uploadFileInternal( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate)); refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
}, },
downloadFile(token, id) { downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id)); .then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
}, },
downloadAppDataFile(token, id) { downloadAppDataFile(token, id) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id)); .then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
}, },
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) { removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(token, driveAppDataScopes)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => this.request(refreshedToken, { .then(ifNotTooLate(refreshedToken => this.request(refreshedToken, {
method: 'DELETE', method: 'DELETE',
@ -290,7 +332,7 @@ export default {
uploadBlogger( uploadBlogger(
token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage, token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage,
) { ) {
return this.refreshToken(bloggerScopes, token) return this.refreshToken(token, bloggerScopes)
.then(refreshedToken => Promise.resolve() .then(refreshedToken => Promise.resolve()
.then(() => { .then(() => {
if (blogId) { if (blogId) {
@ -356,7 +398,7 @@ export default {
openPicker(token, type = 'doc') { openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token); const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
return this.loadClientScript() return this.loadClientScript()
.then(() => this.refreshToken(scopes, token)) .then(() => this.refreshToken(token, scopes))
.then(refreshedToken => new Promise((resolve) => { .then(refreshedToken => new Promise((resolve) => {
let picker; let picker;
const pickerBuilder = new google.picker.PickerBuilder() const pickerBuilder = new google.picker.PickerBuilder()

View File

@ -0,0 +1,53 @@
import store from '../store';
import networkSvc from './networkSvc';
import utils from './utils';
const checkPaymentEvery = 15 * 60 * 1000; // 15 min
let lastCheck = 0;
const appId = 'ESTHdCYOi18iLhhO';
let monetize;
const getMonetize = () => Promise.resolve()
.then(() => networkSvc.loadScript('https://cdn.monetizejs.com/api/js/latest/monetize.min.js'))
.then(() => {
monetize = monetize || new window.MonetizeJS({
applicationID: appId,
});
});
const isGoogleSponsor = () => {
const loginToken = store.getters['data/loginToken'];
return loginToken && loginToken.isSponsor;
};
const checkPayment = () => {
const currentDate = Date.now();
if (!isGoogleSponsor() && utils.isUserActive() && !store.state.offline &&
lastCheck + checkPaymentEvery < currentDate
) {
lastCheck = currentDate;
getMonetize()
.then(() => monetize.getPaymentsImmediate((err, payments) => {
const isSponsor = payments && payments.app === appId && (
(payments.chargeOption && payments.chargeOption.alias === 'once') ||
(payments.subscriptionOption && payments.subscriptionOption.alias === 'yearly'));
if (isSponsor !== store.state.monetizeSponsor) {
store.commit('setMonetizeSponsor', isSponsor);
}
}));
}
};
utils.setInterval(checkPayment, 2000);
export default {
getToken() {
if (isGoogleSponsor() || store.state.offline) {
return Promise.resolve();
}
return getMonetize()
.then(() => new Promise(resolve =>
monetize.getTokenImmediate((err, result) => resolve(result))));
},
};

View File

@ -123,23 +123,45 @@ function createSyncLocation(syncLocation) {
}); });
} }
function syncFile(fileId, needSyncRestartParam = false) { class SyncContext {
let needSyncRestart = needSyncRestartParam; constructor() {
this.restart = false;
this.synced = {};
}
}
class FileSyncContext {
constructor() {
this.downloaded = {};
this.errors = {};
}
}
function syncFile(fileId, syncContext = new SyncContext()) {
const fileSyncContext = new FileSyncContext();
syncContext.synced[`${fileId}/content`] = true;
return localDbSvc.loadSyncedContent(fileId) return localDbSvc.loadSyncedContent(fileId)
.then(() => localDbSvc.loadItem(`${fileId}/content`) .then(() => localDbSvc.loadItem(`${fileId}/content`)
.catch(() => {})) // Item may not exist if content has not been downloaded yet .catch(() => {})) // Item may not exist if content has not been downloaded yet
.then(() => { .then(() => {
const getFile = () => store.state.file.itemMap[fileId];
const getContent = () => store.state.content.itemMap[`${fileId}/content`]; const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`]; const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const downloadedLocations = {};
const errorLocations = {};
const isLocationSynced = (syncLocation) => { const isLocationSynced = (syncLocation) => {
const syncHistoryItem = getSyncHistoryItem(syncLocation.id); const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
return syncHistoryItem && syncHistoryItem[LAST_SENT] === getContent().hash; return syncHistoryItem && syncHistoryItem[LAST_SENT] === getContent().hash;
}; };
const isWelcomeFile = () => {
const file = getFile();
const content = getContent();
const welcomeFileHashes = store.getters['data/welcomeFileHashes'];
const hash = content ? utils.hash(content.text) : 0;
return file.name === 'Welcome file' && welcomeFileHashes[hash];
};
const syncOneContentLocation = () => { const syncOneContentLocation = () => {
const syncLocations = [ const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/groupedByFileId'][fileId] || [],
@ -149,16 +171,21 @@ function syncFile(fileId, needSyncRestartParam = false) {
} }
let result; let result;
syncLocations.some((syncLocation) => { syncLocations.some((syncLocation) => {
if (!errorLocations[syncLocation.id] && const provider = providerRegistry.providers[syncLocation.providerId];
(!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation)) if (
// Skip if it previously threw an error
!fileSyncContext.errors[syncLocation.id] &&
// Skip if it has previously been downloaded and has not changed since then
(!fileSyncContext.downloaded[syncLocation.id] || !isLocationSynced(syncLocation)) &&
// Skip welcome file if not synchronized explicitly
(syncLocations.length > 1 || !isWelcomeFile())
) { ) {
const provider = providerRegistry.providers[syncLocation.providerId];
const token = provider.getToken(syncLocation); const token = provider.getToken(syncLocation);
result = provider && token && store.dispatch('queue/doWithLocation', { result = provider && token && store.dispatch('queue/doWithLocation', {
location: syncLocation, location: syncLocation,
promise: provider.downloadContent(token, syncLocation) promise: provider.downloadContent(token, syncLocation)
.then((serverContent = null) => { .then((serverContent = null) => {
downloadedLocations[syncLocation.id] = true; fileSyncContext.downloaded[syncLocation.id] = true;
const syncedContent = getSyncedContent(); const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id); const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
@ -192,7 +219,7 @@ function syncFile(fileId, needSyncRestartParam = false) {
})(); })();
if (!mergedContent) { if (!mergedContent) {
errorLocations[syncLocation.id] = true; fileSyncContext.errors[syncLocation.id] = true;
return null; return null;
} }
@ -274,9 +301,10 @@ function syncFile(fileId, needSyncRestartParam = false) {
} }
// If content was just created, restart sync to create the file as well // If content was just created, restart sync to create the file as well
const syncDataByItemId = store.getters['data/syncDataByItemId']; if (provider === mainProvider &&
if (!syncDataByItemId[fileId]) { !store.getters['data/syncDataByItemId'][fileId]
needSyncRestart = true; ) {
syncContext.restart = true;
} }
}); });
}) })
@ -286,7 +314,7 @@ function syncFile(fileId, needSyncRestartParam = false) {
} }
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err); store.dispatch('notification/error', err);
errorLocations[syncLocation.id] = true; fileSyncContext.errors[syncLocation.id] = true;
}), }),
}) })
.then(() => syncOneContentLocation()); .then(() => syncOneContentLocation());
@ -304,15 +332,13 @@ function syncFile(fileId, needSyncRestartParam = false) {
.then(() => { .then(() => {
throw err; throw err;
})) }))
.then( .catch((err) => {
() => needSyncRestart, if (err && err.message === 'TOO_LATE') {
(err) => { // Restart sync
if (err && err.message === 'TOO_LATE') { return syncFile(fileId, syncContext);
// Restart sync }
return syncFile(fileId, needSyncRestart); throw err;
} });
throw err;
});
} }
@ -385,6 +411,7 @@ function syncDataItem(dataId) {
} }
function sync() { function sync() {
const syncContext = new SyncContext();
const mainToken = store.getters['data/loginToken']; const mainToken = store.getters['data/loginToken'];
return mainProvider.getChanges(mainToken) return mainProvider.getChanges(mainToken)
.then((changes) => { .then((changes) => {
@ -481,8 +508,12 @@ function sync() {
const loadedContent = store.state.content.itemMap[contentId]; const loadedContent = store.state.content.itemMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId]; const syncData = store.getters['data/syncDataByItemId'][contentId];
// Sync if item hash and syncData hash are inconsistent if (
if (!syncData || hash !== syncData.hash) { // Sync if syncData does not exist and content syncing was not attempted yet
(!syncData && !syncContext.synced[contentId]) ||
// Or if content hash and syncData hash are inconsistent
(syncData && hash !== syncData.hash)
) {
[fileId] = contentId.split('/'); [fileId] = contentId.split('/');
} }
return fileId; return fileId;
@ -490,13 +521,13 @@ function sync() {
return fileId; return fileId;
}; };
const syncNextFile = (needSyncRestartParam) => { const syncNextFile = () => {
const fileId = getOneFileIdToSync(); const fileId = getOneFileIdToSync();
if (!fileId) { if (!fileId) {
return needSyncRestartParam; return null;
} }
return syncFile(fileId, needSyncRestartParam) return syncFile(fileId, syncContext)
.then(needSyncRestart => syncNextFile(needSyncRestart)); .then(() => syncNextFile());
}; };
return Promise.resolve() return Promise.resolve()
@ -508,14 +539,14 @@ function sync() {
const currentFileId = store.getters['file/current'].id; const currentFileId = store.getters['file/current'].id;
if (currentFileId) { if (currentFileId) {
// Sync current file first // Sync current file first
return syncFile(currentFileId) return syncFile(currentFileId, syncContext)
.then(needSyncRestart => syncNextFile(needSyncRestart)); .then(() => syncNextFile());
} }
return syncNextFile(); return syncNextFile();
}) })
.then( .then(
(needSyncRestart) => { () => {
if (needSyncRestart) { if (syncContext.restart) {
// Restart sync // Restart sync
return sync(); return sync();
} }

View File

@ -1,4 +1,5 @@
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import '../libs/clunderscore';
import defaultProperties from '../data/defaultFileProperties.yml'; import defaultProperties from '../data/defaultFileProperties.yml';
const workspaceId = 'main'; const workspaceId = 'main';
@ -157,4 +158,57 @@ export default {
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
return urlParser.href; return urlParser.href;
}, },
wrapRange(range, eltProperties) {
const rangeLength = `${range}`.length;
let wrappedLength = 0;
const treeWalker = document.createTreeWalker(
range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
let startOffset = range.startOffset;
treeWalker.currentNode = range.startContainer;
if (treeWalker.currentNode.nodeType === Node.TEXT_NODE || treeWalker.nextNode()) {
do {
if (treeWalker.currentNode.nodeValue !== '\n') {
if (treeWalker.currentNode === range.endContainer &&
range.endOffset < treeWalker.currentNode.nodeValue.length
) {
treeWalker.currentNode.splitText(range.endOffset);
}
if (startOffset) {
treeWalker.currentNode = treeWalker.currentNode.splitText(startOffset);
startOffset = 0;
}
const elt = document.createElement('span');
Object.keys(eltProperties).forEach((key) => {
elt[key] = eltProperties[key];
});
treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode);
elt.appendChild(treeWalker.currentNode);
}
wrappedLength += treeWalker.currentNode.nodeValue.length;
if (wrappedLength >= rangeLength) {
break;
}
}
while (treeWalker.nextNode());
}
},
unwrapRange(eltCollection) {
Array.prototype.slice.call(eltCollection).forEach((elt) => {
// Loop in case another wrapper has been added inside
for (let child = elt.firstChild; child; child = elt.firstChild) {
if (child.nodeType === 3) {
if (elt.previousSibling && elt.previousSibling.nodeType === 3) {
child.nodeValue = elt.previousSibling.nodeValue + child.nodeValue;
elt.parentNode.removeChild(elt.previousSibling);
}
if (!child.nextSibling && elt.nextSibling && elt.nextSibling.nodeType === 3) {
child.nodeValue += elt.nextSibling.nodeValue;
elt.parentNode.removeChild(elt.nextSibling);
}
}
elt.parentNode.insertBefore(child, elt);
}
elt.parentNode.removeChild(elt);
});
},
}; };

View File

@ -6,6 +6,7 @@ import contentState from './modules/contentState';
import syncedContent from './modules/syncedContent'; import syncedContent from './modules/syncedContent';
import content from './modules/content'; import content from './modules/content';
import file from './modules/file'; import file from './modules/file';
import findReplace from './modules/findReplace';
import folder from './modules/folder'; import folder from './modules/folder';
import publishLocation from './modules/publishLocation'; import publishLocation from './modules/publishLocation';
import syncLocation from './modules/syncLocation'; import syncLocation from './modules/syncLocation';
@ -26,6 +27,7 @@ const store = new Vuex.Store({
ready: false, ready: false,
offline: false, offline: false,
lastOfflineCheck: 0, lastOfflineCheck: 0,
monetizeSponsor: false,
}, },
getters: { getters: {
allItemMap: (state) => { allItemMap: (state) => {
@ -33,6 +35,10 @@ const store = new Vuex.Store({
utils.types.forEach(type => Object.assign(result, state[type].itemMap)); utils.types.forEach(type => Object.assign(result, state[type].itemMap));
return result; return result;
}, },
isSponsor: (state, getters) => {
const loginToken = getters['data/loginToken'];
return state.monetizeSponsor || (loginToken && loginToken.isSponsor);
},
}, },
mutations: { mutations: {
setReady: (state) => { setReady: (state) => {
@ -44,6 +50,12 @@ const store = new Vuex.Store({
updateLastOfflineCheck: (state) => { updateLastOfflineCheck: (state) => {
state.lastOfflineCheck = Date.now(); state.lastOfflineCheck = Date.now();
}, },
setMonetizeSponsor: (state, value) => {
state.monetizeSponsor = value;
},
setGoogleSponsor: (state, value) => {
state.googleSponsor = value;
},
}, },
actions: { actions: {
setOffline: ({ state, commit, dispatch }, value) => { setOffline: ({ state, commit, dispatch }, value) => {
@ -91,6 +103,7 @@ const store = new Vuex.Store({
syncedContent, syncedContent,
content, content,
file, file,
findReplace,
folder, folder,
publishLocation, publishLocation,
syncLocation, syncLocation,

View File

@ -6,6 +6,7 @@ import defaultSettings from '../../data/defaultSettings.yml';
import defaultLocalSettings from '../../data/defaultLocalSettings'; import defaultLocalSettings from '../../data/defaultLocalSettings';
import plainHtmlTemplate from '../../data/plainHtmlTemplate.html'; import plainHtmlTemplate from '../../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../../data/styledHtmlTemplate.html'; import styledHtmlTemplate from '../../data/styledHtmlTemplate.html';
import styledHtmlWithTocTemplate from '../../data/styledHtmlWithTocTemplate.html';
import jekyllSiteTemplate from '../../data/jekyllSiteTemplate.html'; import jekyllSiteTemplate from '../../data/jekyllSiteTemplate.html';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 }); const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
@ -61,15 +62,37 @@ module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
module.actions.toggleEditor = localSettingsToggler('showEditor'); module.actions.toggleEditor = localSettingsToggler('showEditor');
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview'); module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
module.actions.toggleStatusBar = localSettingsToggler('showStatusBar'); module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
module.actions.toggleSideBar = ({ getters, dispatch }, value) => {
dispatch('setSideBarPanel'); // Reset side bar
dispatch('patchLocalSettings', {
showSideBar: value === undefined ? !getters.localSettings.showSideBar : value,
});
};
module.actions.toggleExplorer = localSettingsToggler('showExplorer');
module.actions.toggleScrollSync = localSettingsToggler('scrollSync'); module.actions.toggleScrollSync = localSettingsToggler('scrollSync');
module.actions.toggleFocusMode = localSettingsToggler('focusMode'); module.actions.toggleFocusMode = localSettingsToggler('focusMode');
const notEnoughSpace = (rootGetters) => {
const constants = rootGetters['layout/constants'];
return document.body.clientWidth < constants.editorMinWidth +
constants.explorerWidth +
constants.sideBarWidth +
constants.buttonBarWidth;
};
module.actions.toggleSideBar = ({ getters, dispatch, rootGetters }, value) => {
// Reset side bar
dispatch('setSideBarPanel');
// Close explorer if not enough space
const patch = {
showSideBar: value === undefined ? !getters.localSettings.showSideBar : value,
};
if (patch.showSideBar && notEnoughSpace(rootGetters)) {
patch.showExplorer = false;
}
dispatch('patchLocalSettings', patch);
};
module.actions.toggleExplorer = ({ getters, dispatch, rootGetters }, value) => {
// Close side bar if not enough space
const patch = {
showExplorer: value === undefined ? !getters.localSettings.showExplorer : value,
};
if (patch.showExplorer && notEnoughSpace(rootGetters)) {
patch.showSideBar = false;
}
dispatch('patchLocalSettings', patch);
};
module.actions.setSideBarPanel = ({ dispatch }, value) => dispatch('patchLocalSettings', { module.actions.setSideBarPanel = ({ dispatch }, value) => dispatch('patchLocalSettings', {
sideBarPanel: value === undefined ? 'menu' : value, sideBarPanel: value === undefined ? 'menu' : value,
}); });
@ -112,6 +135,7 @@ const additionalTemplates = {
plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'), plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate), plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate), styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
}; };
module.getters.allTemplates = (state, getters) => ({ module.getters.allTemplates = (state, getters) => ({
@ -187,6 +211,10 @@ module.actions.setSyncData = setter('syncData');
module.getters.dataSyncData = getter('dataSyncData'); module.getters.dataSyncData = getter('dataSyncData');
module.actions.patchDataSyncData = patcher('dataSyncData'); module.actions.patchDataSyncData = patcher('dataSyncData');
// Welcome file content hashes (used as a file sync blacklist)
module.getters.welcomeFileHashes = getter('welcomeFileHashes');
module.actions.patchWelcomeFileHashes = patcher('welcomeFileHashes');
// Tokens // Tokens
module.getters.tokens = getter('tokens'); module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {}; module.getters.googleTokens = (state, getters) => getters.tokens.google || {};

View File

@ -0,0 +1,32 @@
export default {
namespaced: true,
state: {
type: null,
lastOpen: 0,
findText: '',
replaceText: '',
},
mutations: {
setType: (state, value) => {
state.type = value;
},
setLastOpen: (state) => {
state.lastOpen = Date.now();
},
setFindText: (state, value) => {
state.findText = value;
},
setReplaceText: (state, value) => {
state.replaceText = value;
},
},
actions: {
open({ commit, state }, { type, findText }) {
commit('setType', type);
if (findText) {
commit('setFindText', findText);
}
commit('setLastOpen');
},
},
};

View File

@ -1,4 +1,3 @@
const editorMinWidth = 320;
const minPadding = 20; const minPadding = 20;
const previewButtonWidth = 55; const previewButtonWidth = 55;
const editorTopPadding = 10; const editorTopPadding = 10;
@ -13,6 +12,7 @@ const maxTitleMaxWidth = 800;
const minTitleMaxWidth = 200; const minTitleMaxWidth = 200;
const constants = { const constants = {
editorMinWidth: 320,
explorerWidth: 250, explorerWidth: 250,
sideBarWidth: 280, sideBarWidth: 280,
navigationBarHeight: 44, navigationBarHeight: 44,
@ -28,6 +28,7 @@ function computeStyles(state, localSettings, getters, styles = {
showPreview: localSettings.showSidePreview || !localSettings.showEditor, showPreview: localSettings.showSidePreview || !localSettings.showEditor,
showSideBar: localSettings.showSideBar, showSideBar: localSettings.showSideBar,
showExplorer: localSettings.showExplorer, showExplorer: localSettings.showExplorer,
layoutOverflow: false,
}) { }) {
styles.innerHeight = state.bodyHeight; styles.innerHeight = state.bodyHeight;
if (styles.showNavigationBar) { if (styles.showNavigationBar) {
@ -46,14 +47,16 @@ function computeStyles(state, localSettings, getters, styles = {
} }
let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth; let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth;
if (doublePanelWidth < editorMinWidth) { if (doublePanelWidth < constants.editorMinWidth) {
doublePanelWidth = editorMinWidth; doublePanelWidth = constants.editorMinWidth;
styles.innerWidth = editorMinWidth + constants.buttonBarWidth; styles.innerWidth = constants.editorMinWidth + constants.buttonBarWidth;
styles.layoutOverflow = true;
} }
if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) { if (styles.showSidePreview && doublePanelWidth / 2 < constants.editorMinWidth) {
styles.showSidePreview = false; styles.showSidePreview = false;
styles.showPreview = false; styles.showPreview = false;
styles.layoutOverflow = false;
return computeStyles(state, localSettings, getters, styles); return computeStyles(state, localSettings, getters, styles);
} }

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