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

View File

@ -96,7 +96,7 @@ var webpackConfig = merge(baseWebpackConfig, {
ServiceWorker: {
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']
})
]

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">
<title>StackEdit</title>
<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="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes">
@ -18,5 +13,6 @@
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="//cdn.monetizejs.com/api/js/latest/monetize.min.js"></script>
</body>
</html>

View File

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

View File

@ -1,7 +1,13 @@
const compression = require('compression');
const serveStatic = require('serve-static');
const bodyParser = require('body-parser');
const path = require('path');
const user = require('./user');
const github = require('./github');
const pdf = require('./pdf');
const pandoc = require('./pandoc');
const resolvePath = pathToResolve => path.join(__dirname, '..', pathToResolve);
module.exports = (app, serveV4) => {
// Use gzip compression
@ -22,10 +28,20 @@ module.exports = (app, serveV4) => {
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('/userInfo', user.userInfo);
app.post('/paypalIpn', user.paypalIpn);
app.post('/pdfExport', pdf.generate);
app.post('/pandocExport', pandoc.generate);
if (serveV4) {
/* 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('/picasaImportImg', require('../stackedit_v4/app/picasa').importImg);
app.get('/downloadImport', require('../stackedit_v4/app/download').importPublic);
@ -33,29 +49,39 @@ module.exports = (app, serveV4) => {
}
// 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
if (process.env.NODE_ENV === 'production') {
if (serveV4) {
// 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
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
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
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) {
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
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>
<div class="button-bar">
<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>
</div>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.showSidePreview }" @click="toggleSidePreview()" v-title="'Toggle side preview'">
</button>
<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>
</div>
<div class="button-bar__button" @click="toggleEditor(false)" v-title="'Reader mode'">
</button>
<button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'">
<icon-eye></icon-eye>
</div>
</button>
</div>
<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>
</div>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
</button>
<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>
</div>
<div class="button-bar__button" :class="{ 'button-bar__button--on': localSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
</button>
<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>
</div>
</button>
</div>
</div>
</template>
@ -60,19 +60,27 @@ export default {
}
.button-bar__button {
cursor: pointer;
display: block;
color: rgba(0, 0, 0, 0.2);
width: 26px;
height: 26px;
padding: 2px;
margin: 3px 0;
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.4);
color: rgba(0, 0, 0, 0.2);
}
}
.button-bar__button--on {
color: rgba(0, 0, 0, 0.4);
&:active,
&:focus,
&:hover {
color: rgba(0, 0, 0, 0.4);
}
}
</style>

View File

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

View File

@ -22,7 +22,7 @@ import { mapMutations, mapActions } from 'vuex';
import utils from '../services/utils';
export default {
name: 'explorer-node',
name: 'explorer-node', // Required for recursivity
props: ['node', 'depth'],
data: () => ({
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>
<div class="layout">
<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>
</div>
<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 layout__panel--editor" v-show="styles.showEditor" :style="{ width: styles.editorWidth + 'px', 'font-size': styles.fontSize + 'px' }">
<editor></editor>
<div v-if="showFindReplace" class="layout__panel layout__panel--find-replace">
<find-replace></find-replace>
</div>
</div>
<div class="layout__panel layout__panel--button-bar" v-show="styles.showEditor" :style="{ width: constants.buttonBarWidth + 'px' }">
<button-bar></button-bar>
@ -23,7 +26,7 @@
<status-bar></status-bar>
</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>
</div>
</div>
@ -39,7 +42,9 @@ import Explorer from './Explorer';
import SideBar from './SideBar';
import Editor from './Editor';
import Preview from './Preview';
import FindReplace from './FindReplace';
import editorSvc from '../services/editorSvc';
import editorEngineSvc from '../services/editorEngineSvc';
export default {
components: {
@ -50,12 +55,16 @@ export default {
SideBar,
Editor,
Preview,
FindReplace,
},
computed: {
...mapGetters('layout', [
'constants',
'styles',
]),
showFindReplace() {
return !!this.$store.state.findReplace.type;
},
},
methods: {
...mapMutations('layout', [
@ -75,6 +84,10 @@ export default {
const previewElt = this.$el.querySelector('.preview__inner-2');
const tocElt = this.$el.querySelector('.toc__inner');
editorSvc.init(editorElt, previewElt, tocElt);
// Focus on the editor every time reader mode is disabled
this.$watch(() => this.styles.showEditor,
showEditor => showEditor && editorEngineSvc.clEditor.focus());
},
destroyed() {
window.removeEventListener('resize', this.updateStyle);
@ -122,4 +135,14 @@ export default {
.layout__panel--side-bar {
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>

View File

@ -5,18 +5,23 @@
<templates-modal v-else-if="config.type === 'templates'"></templates-modal>
<about-modal v-else-if="config.type === 'about'"></about-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>
<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>
<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>
<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>
<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>
<gist-sync-modal v-else-if="config.type === 'gistSync'"></gist-sync-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>
<zendesk-account-modal v-else-if="config.type === 'zendeskAccount'"></zendesk-account-modal>
<zendesk-publish-modal v-else-if="config.type === 'zendeskPublish'"></zendesk-publish-modal>
<div v-else class="modal__inner-1">
<div class="modal__inner-2">
<modal-inner v-else aria-label="Dialog">
<div class="modal__content" v-html="config.content"></div>
<div class="modal__button-bar">
<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>
</div>
</div>
</div>
</modal-inner>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import editorEngineSvc from '../services/editorEngineSvc';
import ModalInner from './modals/common/ModalInner';
import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal';
import AboutModal from './modals/AboutModal';
import HtmlExportModal from './modals/HtmlExportModal';
import PdfExportModal from './modals/PdfExportModal';
import PandocExportModal from './modals/PandocExportModal';
import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal';
import GooglePhotoModal from './modals/GooglePhotoModal';
import SyncManagementModal from './modals/SyncManagementModal';
import PublishManagementModal from './modals/PublishManagementModal';
import GoogleDriveSyncModal from './modals/GoogleDriveSyncModal';
import GoogleDrivePublishModal from './modals/GoogleDrivePublishModal';
import DropboxAccountModal from './modals/DropboxAccountModal';
import DropboxSyncModal from './modals/DropboxSyncModal';
import DropboxPublishModal from './modals/DropboxPublishModal';
import GithubAccountModal from './modals/GithubAccountModal';
import GithubSyncModal from './modals/GithubSyncModal';
import GithubPublishModal from './modals/GithubPublishModal';
import GistSyncModal from './modals/GistSyncModal';
import GistPublishModal from './modals/GistPublishModal';
import WordpressPublishModal from './modals/WordpressPublishModal';
import BloggerPublishModal from './modals/BloggerPublishModal';
import BloggerPagePublishModal from './modals/BloggerPagePublishModal';
import ZendeskAccountModal from './modals/ZendeskAccountModal';
import ZendeskPublishModal from './modals/ZendeskPublishModal';
import SponsorModal from './modals/SponsorModal';
// Providers
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
import DropboxAccountModal from './modals/providers/DropboxAccountModal';
import DropboxSaveModal from './modals/providers/DropboxSaveModal';
import DropboxPublishModal from './modals/providers/DropboxPublishModal';
import GithubAccountModal from './modals/providers/GithubAccountModal';
import GithubOpenModal from './modals/providers/GithubOpenModal';
import GithubSaveModal from './modals/providers/GithubSaveModal';
import GithubPublishModal from './modals/providers/GithubPublishModal';
import GistSyncModal from './modals/providers/GistSyncModal';
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')
// 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 {
components: {
ModalInner,
FilePropertiesModal,
SettingsModal,
TemplatesModal,
AboutModal,
HtmlExportModal,
PdfExportModal,
PandocExportModal,
LinkModal,
ImageModal,
GooglePhotoModal,
SyncManagementModal,
PublishManagementModal,
GoogleDriveSyncModal,
SponsorModal,
// Providers
GooglePhotoModal,
GoogleDriveSaveModal,
GoogleDrivePublishModal,
DropboxAccountModal,
DropboxSyncModal,
DropboxSaveModal,
DropboxPublishModal,
GithubAccountModal,
GithubSyncModal,
GithubOpenModal,
GithubSaveModal,
GithubPublishModal,
GistSyncModal,
GistPublishModal,
@ -160,7 +176,7 @@ export default {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(128, 128, 128, 0.5);
background-color: rgba(160, 160, 160, 0.5);
overflow: auto;
}
@ -172,7 +188,7 @@ export default {
}
.modal__inner-2 {
margin: 50px 10px 100px;
margin: 40px 10px 100px;
background-color: #fff;
padding: 40px 50px 30px;
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;
}
@ -309,7 +326,7 @@ export default {
.tabs {
border-bottom: 1px solid $hr-color;
margin-bottom: 2em;
margin: 1em 0 2em;
&::after {
content: '';

View File

@ -1,5 +1,5 @@
<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">
<button class="navigation-bar__button button" @click="toggleExplorer()" v-title="'Toggle explorer'">
<icon-folder></icon-folder>
@ -78,7 +78,7 @@
<icon-format-horizontal-rule></icon-format-horizontal-rule>
</button>
</div>
</div>
</nav>
</template>
<script>

View File

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

View File

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

View File

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

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,
input,
select,
@ -78,6 +88,7 @@ textarea {
background-image: none;
border: 0;
border-radius: $border-radius-base;
text-decoration: none;
&:active,
&:focus,
@ -195,6 +206,12 @@ textarea {
background-size: contain;
}
.hidden-rendering-container {
position: absolute;
width: 500px;
left: -1000px;
}
@media print {
body {
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 './variables.scss';
@import './fonts.scss';
@import './prism';
@import './mermaid';
@include normalize();
html,
body {
color: rgba(0, 0, 0, 0.75);
font-size: 16px;
font-family: $font-family-main;
font-variant-ligatures: common-ligatures;
line-height: $line-height-base;
@ -16,12 +18,6 @@ body {
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
@ -37,26 +33,19 @@ h3,
h4,
h5,
h6 {
margin: 1.8em 0 1.2em;
margin: 1.8em 0;
line-height: $line-height-title;
padding: 0.33em 0;
}
h1,
h2 {
&::after {
content: '';
display: block;
position: relative;
top: 0.33em;
border-bottom: 1px solid $hr-color;
}
h1 {
font-size: 2.2em;
}
h2 {
font-size: 1.6em;
}
h3 {
font-size: 1.22em;
}
ol ul,
@ -68,11 +57,12 @@ ol ol {
a {
color: $link-color;
text-decoration: none;
text-decoration: underline;
text-decoration-skip: ink;
&:hover,
&:focus {
text-decoration: underline;
text-decoration: none;
}
}
@ -89,8 +79,8 @@ samp {
blockquote {
color: rgba(0, 0, 0, 0.5);
margin-left: 1em;
margin-right: 1em;
padding-left: 1.5em;
border-left: 5px solid rgba(0, 0, 0, 0.075);
}
code {
@ -108,9 +98,9 @@ hr {
pre > code {
background-color: $code-bg;
display: block;
overflow-x: auto;
padding: 0.5em;
-webkit-text-size-adjust: none;
white-space: pre-wrap;
}
.toc ul {
@ -126,7 +116,7 @@ table {
td,
th {
border-right: 1px solid #e0e0e0;
border-right: 1px solid #dcdcdc;
padding: 8px 12px;
&:last-child {
@ -135,7 +125,7 @@ th {
}
td {
border-top: 1px solid #e0e0e0;
border-top: 1px solid #dcdcdc;
}
kbd {
@ -163,11 +153,6 @@ img {
max-width: 100%;
}
.table-wrapper {
max-width: 100%;
overflow: auto;
}
.footnote {
font-size: 0.8em;
position: relative;
@ -175,18 +160,93 @@ img {
vertical-align: top;
}
.export-container {
.stackedit__html {
margin-bottom: 180px;
margin-left: auto;
margin-right: auto;
padding-left: 30px;
padding-right: 30px;
> :not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):not([align]) {
text-align: justify;
}
}
@media (min-width: 768px) {
.export-container {
@media (min-width: 810px) {
.stackedit__html {
width: 750px;
}
}
}
.stackedit__toc {
ul {
padding: 0;
a {
margin: 0.5rem 0;
padding: 0.5rem 1rem;
}
ul {
color: #888;
font-size: 0.9em;
a {
margin: 0;
padding: 0.1rem 1rem;
}
}
}
li {
display: block;
}
a {
display: block;
color: inherit;
text-decoration: none;
&:active,
&:focus,
&:hover {
background-color: rgba(0, 0, 0, 0.075);
border-radius: $border-radius-base;
}
}
}
.stackedit__left {
position: fixed;
display: none;
width: 250px;
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 @click.native="exportPdf">
<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>
</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>
</template>
<script>
import MenuEntry from './MenuEntry';
import MenuEntry from './common/MenuEntry';
import exportSvc from '../../services/exportSvc';
export default {
@ -37,7 +42,12 @@ export default {
.catch(() => {}); // Cancel
},
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')">
<icon-upload slot="icon"></icon-upload>
<div>Publish</div>
<span>Export to the web.</span>
<span>Export your file to the web.</span>
</menu-entry>
<hr>
<menu-entry @click.native="fileProperties">
@ -63,7 +63,7 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './MenuEntry';
import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc';

View File

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

View File

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

View File

@ -44,6 +44,11 @@
</menu-entry>
</div>
<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)">
<icon-provider slot="icon" provider-id="github"></icon-provider>
<div>Save on GitHub</div>
@ -73,12 +78,13 @@
<script>
import { mapState, mapGetters } from 'vuex';
import MenuEntry from './MenuEntry';
import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
import dropboxProvider from '../../services/providers/dropboxProvider';
import githubProvider from '../../services/providers/githubProvider';
import syncSvc from '../../services/syncSvc';
import store from '../../store';
@ -161,15 +167,23 @@ export default {
() => dropboxProvider.openFiles(token, paths)));
},
saveGoogleDrive(token) {
return openSyncModal(token, 'googleDriveSync')
return openSyncModal(token, 'googleDriveSave')
.catch(() => {}); // Cancel
},
saveDropbox(token) {
return openSyncModal(token, 'dropboxSync')
return openSyncModal(token, 'dropboxSave')
.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) {
return openSyncModal(token, 'githubSync')
return openSyncModal(token, 'githubSave')
.catch(() => {}); // Cancel
},
saveGist(token) {

View File

@ -3,26 +3,34 @@
<div class="menu-entry__icon flex flex--column flex--center">
<slot name="icon"></slot>
</div>
<div class="flex flex--column">
<div class="menu-entry__text flex flex--column">
<slot></slot>
</div>
</a>
</template>
<style lang="scss">
@import '../common/variables.scss';
@import '../../common/variables.scss';
.menu-entry {
text-align: left;
padding: 10px;
height: auto;
div div {
text-decoration: underline;
text-decoration-skip: ink;
&.menu-entry__sponsor {
text-decoration: none;
}
}
span {
display: inline-block;
font-size: 0.75em;
font-size: 0.75rem;
opacity: 0.5;
white-space: normal;
line-height: 1.4;
}
}
@ -42,4 +50,18 @@
position: fixed;
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>

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1 modal__inner-1--file-properties" role="dialog" aria-label="File properties">
<div class="modal__inner-2">
<modal-inner class="modal__inner-1--file-properties" aria-label="File properties">
<div class="modal__content">
<div class="tabs flex flex--row">
<tab :active="tab === 'custom'" @click="tab = 'custom'">
Current file properties
@ -22,26 +22,30 @@
</div>
</div>
<div class="modal__error modal__error--file-properties">{{error}}</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>
</modal-inner>
</template>
<script>
import yaml from 'js-yaml';
import { mapGetters } from 'vuex';
import Tab from './Tab';
import ModalInner from './common/ModalInner';
import Tab from './common/Tab';
import CodeEditor from '../CodeEditor';
import utils from '../../services/utils';
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 {
components: {
ModalInner,
Tab,
CodeEditor,
},

View File

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

View File

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

View File

@ -1,20 +1,20 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Insert link">
<div class="modal__inner-2">
<p>Please provide a <b>URL</b> for your link.
<modal-inner aria-label="Insert link">
<div class="modal__content">
<p>Please provide a <b>URL</b> for your link.</p>
<form-entry label="URL" error="url">
<input slot="field" class="textfield" type="text" v-model.trim="url" @keyup.enter="resolve()">
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from './modalTemplate';
import modalTemplate from './common/modalTemplate';
export default modalTemplate({
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>
<div class="modal__inner-1 modal__inner-1--publish-management" role="dialog" aria-label="Manage publication locations">
<div class="modal__inner-2">
<modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations">
<div class="modal__content">
<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>
<div>
@ -24,18 +24,22 @@
<div class="modal__info" v-if="publishLocations.length">
<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>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default {
components: {
ModalInner,
},
computed: {
...mapGetters('modal', [
'config',

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1 modal__inner-1--settings" role="dialog" aria-label="Settings">
<div class="modal__inner-2">
<modal-inner class="modal__inner-1--settings" aria-label="Settings">
<div class="modal__content">
<div class="tabs flex flex--row">
<tab :active="tab === 'custom'" @click="tab = 'custom'">
Custom settings
@ -22,25 +22,29 @@
</div>
</div>
<div class="modal__error modal__error--settings">{{error}}</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>
</div>
</div>
</modal-inner>
</template>
<script>
import yaml from 'js-yaml';
import { mapGetters } from 'vuex';
import Tab from './Tab';
import ModalInner from './common/ModalInner';
import Tab from './common/Tab';
import CodeEditor from '../CodeEditor';
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 {
components: {
ModalInner,
Tab,
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>
<div class="modal__inner-1 modal__inner-1--sync-management" role="dialog" aria-label="Manage synchronized locations">
<div class="modal__inner-2">
<modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations">
<div class="modal__content">
<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>
<div>
@ -24,18 +24,22 @@
<div class="modal__info" v-if="syncLocations.length">
<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>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default {
components: {
ModalInner,
},
computed: {
...mapGetters('modal', [
'config',

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1 modal__inner-1--templates" role="dialog" aria-label="Manage templates">
<div class="modal__inner-2">
<modal-inner class="modal__inner-1--templates" aria-label="Manage templates">
<div class="modal__content">
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
@ -33,7 +33,7 @@
</div>
</div>
<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>
<br>
<label class="form-entry__label">Helpers</label>
@ -42,17 +42,18 @@
</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>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import utils from '../../services/utils';
import ModalInner from './common/ModalInner';
import CodeEditor from '../CodeEditor';
import emptyTemplateValue from '../../data/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../../data/emptyTemplateHelpers.js'; // eslint-disable-line
@ -70,6 +71,7 @@ function fillEmptyFields(template) {
export default {
components: {
ModalInner,
CodeEditor,
},
data: () => ({

View File

@ -9,7 +9,7 @@
</template>
<script>
import utils from '../../services/utils';
import utils from '../../../services/utils';
export default {
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 store from '../../store';
import store from '../../../store';
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
export default (desc) => {
const component = {
@ -10,6 +13,7 @@ export default (desc) => {
}),
components: {
...desc.components || {},
ModalInner,
FormEntry,
},
computed: {
@ -49,8 +53,18 @@ export default (desc) => {
},
};
if (key === 'selectedTemplate') {
component.computed.allTemplates = () => store.getters['data/allTemplates'];
component.methods.configureTemplates = () => {
component.computed.allTemplates = () => {
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', {
type: 'templates',
selectedId: this.selectedTemplate,

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Blogger Page">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Blogger Page">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="bloggerPage"></icon-provider>
</div>
@ -27,17 +27,17 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import bloggerPageProvider from '../../services/providers/bloggerPageProvider';
import modalTemplate from './modalTemplate';
import bloggerPageProvider from '../../../services/providers/bloggerPageProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Blogger">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Blogger">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="blogger"></icon-provider>
</div>
@ -28,17 +28,17 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import bloggerProvider from '../../services/providers/bloggerProvider';
import modalTemplate from './modalTemplate';
import bloggerProvider from '../../../services/providers/bloggerProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Link Dropbox account">
<div class="modal__inner-2">
<modal-inner aria-label="Link Dropbox account">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
@ -15,16 +15,16 @@
</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>
</modal-inner>
</template>
<script>
import modalTemplate from './modalTemplate';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
computedLocalSettings: {

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Dropbox">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Dropbox">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
@ -22,17 +22,17 @@
<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>
</div>
</div>
</modal-inner>
</template>
<script>
import dropboxProvider from '../../services/providers/dropboxProvider';
import modalTemplate from './modalTemplate';
import dropboxProvider from '../../../services/providers/dropboxProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Dropbox">
<div class="modal__inner-2">
<modal-inner aria-label="Synchronize with Dropbox">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
@ -12,17 +12,17 @@
If the file exists, it will be replaced.
</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>
</div>
</div>
</modal-inner>
</template>
<script>
import dropboxProvider from '../../services/providers/dropboxProvider';
import modalTemplate from './modalTemplate';
import dropboxProvider from '../../../services/providers/dropboxProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Gist">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Gist">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gist"></icon-provider>
</div>
@ -34,17 +34,17 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import gistProvider from '../../services/providers/gistProvider';
import modalTemplate from './modalTemplate';
import gistProvider from '../../../services/providers/gistProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Gist">
<div class="modal__inner-2">
<modal-inner aria-label="Synchronize with Gist">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gist"></icon-provider>
</div>
@ -21,17 +21,17 @@
If the file exists in the Gist, it will be replaced.
</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>
</div>
</div>
</modal-inner>
</template>
<script>
import gistProvider from '../../services/providers/gistProvider';
import modalTemplate from './modalTemplate';
import gistProvider from '../../../services/providers/gistProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Link GitHub account">
<div class="modal__inner-2">
<modal-inner aria-label="Link GitHub account">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
@ -8,20 +8,20 @@
<div class="form-entry">
<div class="form-entry__checkbox">
<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>
</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>
</modal-inner>
</template>
<script>
import modalTemplate from './modalTemplate';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
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>
<div class="modal__inner-1" role="dialog" aria-label="Publish to GitHub">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to GitHub">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
@ -34,17 +34,17 @@
<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>
</div>
</div>
</modal-inner>
</template>
<script>
import githubProvider from '../../services/providers/githubProvider';
import modalTemplate from './modalTemplate';
import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with GitHub">
<div class="modal__inner-2">
<modal-inner aria-label="Synchronize with GitHub">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
@ -14,7 +14,7 @@
<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 master branch will be used.
If not provided, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="File path" error="path">
@ -24,17 +24,17 @@
If the file exists, it will be replaced.
</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>
</div>
</div>
</modal-inner>
</template>
<script>
import githubProvider from '../../services/providers/githubProvider';
import modalTemplate from './modalTemplate';
import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({
@ -56,13 +56,13 @@ export default modalTemplate({
this.setError('path');
}
if (this.repoUrl && this.path) {
const parsedRepo = this.repoUrl.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {
// Return new location
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);
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Google Drive">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Google Drive">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
@ -45,18 +45,18 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import googleHelper from '../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
import modalTemplate from './modalTemplate';
import googleHelper from '../../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../../services/providers/googleDriveProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Synchronize with Google Drive">
<div class="modal__inner-2">
<modal-inner aria-label="Synchronize with Google Drive">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
@ -20,18 +20,18 @@
This will overwrite the file on the server.
</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>
</div>
</div>
</modal-inner>
</template>
<script>
import googleHelper from '../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider';
import modalTemplate from './modalTemplate';
import googleHelper from '../../../services/providers/helpers/googleHelper';
import googleDriveProvider from '../../../services/providers/googleDriveProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to WordPress">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to WordPress">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="wordpress"></icon-provider>
</div>
@ -30,17 +30,17 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import wordpressProvider from '../../services/providers/wordpressProvider';
import modalTemplate from './modalTemplate';
import wordpressProvider from '../../../services/providers/wordpressProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div class="modal__inner-1" role="dialog" aria-label="Publish to Zendesk">
<div class="modal__inner-2">
<modal-inner aria-label="Publish to Zendesk">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider>
</div>
@ -34,17 +34,17 @@
<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>.
</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>
</modal-inner>
</template>
<script>
import zendeskProvider from '../../services/providers/zendeskProvider';
import modalTemplate from './modalTemplate';
import zendeskProvider from '../../../services/providers/zendeskProvider';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
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:
#title: My article
@ -26,7 +27,7 @@ extensions:
sup: true
table: true
typographer: true
# For strict CommonMark:
# Enable strict CommonMark:
#abbr: false
#deflist: false
#del: false
@ -37,6 +38,25 @@ extensions:
#table: false
#typographer: false
# Emoji extension
emoji:
# Enable support for emojis & emoticons
enabled: true
# Enable shortcuts like :) :-(
shortcuts: false
# Katex extension
# Render LaTeX mathematical expressions by using:
# $...$ for inline formulas
# $$...$$ for displayed formulas.
# See https://math.meta.stackexchange.com/questions/5020
katex:
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,
scrollSync: true,
focusMode: false,
findCaseSensitive: false,
findUseRegexp: false,
sideBarPanel: 'menu',
htmlExportTemplate: 'styledHtml',
pdfExportTemplate: 'styledHtml',
pandocExportFormat: 'pdf',
googleDriveFolderId: '',
googleDrivePublishFormat: 'markdown',
googleDrivePublishTemplate: 'styledHtml',

View File

@ -10,9 +10,13 @@ editor:
# Use monospaced font only
monospacedFontOnly: false
# Keyboard shortcuts (see https://craig.is/killing/mice)
# Keyboard shortcuts
# See https://craig.is/killing/mice
shortcuts:
mod+s: sync
mod+f: find
mod+alt+f: replace
mod+g: replace
mod+shift+b: bold
mod+shift+i: italic
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
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:
{{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a clickable TOC.

View File

@ -5,12 +5,15 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{files.0.name}}</title>
<link rel="stylesheet" href="http://app.classeur.io/base-min.css" />
<script type="text/javascript" src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"></script>
<link rel="stylesheet" href="https://stackedit.io/style.css" />
</head>
<body>
<div class="export-container">{{{files.0.content.html}}}</div>
{{#if pdf}}
<body class="stackedit stackedit--pdf">
{{else}}
<body class="stackedit">
{{/if}}
<div class="stackedit__html">{{{files.0.content.html}}}</div>
</body>
</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!
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:**
>
> - 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.
> - 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).
## Create files and folders
#### <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.
> - 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.
## Open a file
#### <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),
- HTML, to publish the document converted into HTML (on a blog for example),
- Template, to have a full control of the output.
## Update a publication
> **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.
> **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
SmartyPants converts ASCII punctuation characters into "smart" typographic punctuation HTML entities. For example:
| |ASCII |HTML |
----------------- | ---------------------------- | ------------------
|----------------|-------------------------------|-----------------------------|
|Single backticks|`'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|
### Table of contents
## KaTeX
You can insert a table of contents using the marker `[TOC]`:
[TOC]
### MathJax
You can render *LaTeX* mathematical expressions using **MathJax**, as on [math.stackexchange.com][1]:
You can render LaTeX mathematical expressions using [KaTeX](https://khan.github.io/KaTeX/):
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\,.
$$
> **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:
### UML diagrams
You can also render sequence diagrams like this:
```sequence
Alice->Bob: Hello Bob, how are you?
Note right of Bob: Bob thinks
Bob-->Alice: I am good thanks!
```mermaid
graph LR
A[Square Rect] -- Link text --> B((Circle))
A --> C(Round Rect)
B --> D{Rhombus}
C --> D
```
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 './markdownExt';
import './emojiExtension';
import './katexExtension';
import './markdownExtension';
import './mermaidExtension';

View File

@ -178,9 +178,9 @@ extensionSvc.onInitConverter(0, (markdown, options) => {
extensionSvc.onSectionPreview((elt) => {
elt.querySelectorAll('.prism').cl_each((prismElt) => {
if (!prismElt.highlighted) {
if (!prismElt.highlightedWithPrism) {
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 SignalOff from './SignalOff';
import Folder from './Folder';
import File from './File';
import ScrollSync from './ScrollSync';
import Printer from './Printer';
import Undo from './Undo';
@ -87,6 +88,7 @@ Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert);
Vue.component('iconSignalOff', SignalOff);
Vue.component('iconFolder', Folder);
Vue.component('iconFile', File);
Vue.component('iconScrollSync', ScrollSync);
Vue.component('iconPrinter', Printer);
Vue.component('iconUndo', Undo);

View File

@ -1,4 +1,6 @@
import Vue from 'vue';
import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim';
import * as OfflinePluginRuntime from 'offline-plugin/runtime';
import './extensions/';
import './services/optional';
@ -7,6 +9,10 @@ import App from './components/App';
import store from './store';
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') {
OfflinePluginRuntime.install({
onUpdateReady: () => {

View File

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

View File

@ -49,12 +49,16 @@ function SelectionMgr(editor) {
this.$trigger('cursorCoordinatesChanged', coordinates)
}
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
// Adjust cursorTop with contentElt position relative to scrollElt
cursorTop += contentElt.getBoundingClientRect().top - scrollElt.getBoundingClientRect().top + scrollElt.scrollTop;
var minScrollTop = cursorTop - adjustment
var maxScrollTop = cursorTop + adjustment - scrollElt.clientHeight
var maxScrollTop = cursorTop + adjustment - scrollEltHeight
if (scrollElt.scrollTop > minScrollTop) {
scrollElt.scrollTop = minScrollTop
} else if (scrollElt.scrollTop < maxScrollTop) {
@ -84,6 +88,10 @@ function SelectionMgr(editor) {
}
}
this.hasFocus = function() {
return contentElt === editor.$document.activeElement;
}
this.restoreSelection = function () {
var min = Math.min(this.selectionStart, this.selectionEnd)
var max = Math.max(this.selectionStart, this.selectionEnd)
@ -132,9 +140,9 @@ function SelectionMgr(editor) {
saveLastSelection()
}
this.setSelectionStartEnd = function (start, end, focus) {
this.setSelectionStartEnd = function (start, end) {
setSelection(start, end)
return focus !== false && this.restoreSelection()
return this.hasFocus() && this.restoreSelection()
}
this.saveSelectionState = (function () {
@ -230,10 +238,11 @@ function SelectionMgr(editor) {
}
function save() {
var result
if (self.hasFocus()) {
var selectionStart = self.selectionStart
var selectionEnd = self.selectionEnd
var selection = editor.$window.getSelection()
var result
if (selection.rangeCount > 0) {
var selectionRange = selection.getRangeAt(0)
var node = selectionRange.startContainer
@ -275,6 +284,7 @@ function SelectionMgr(editor) {
}
}
}
}
return result
}

View File

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

View File

@ -131,24 +131,25 @@ export default {
initClEditor(opts) {
const content = store.getters['content/current'];
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) {
contentId = content.id;
currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap);
previousPatchableText = currentPatchableText;
syncDiscussionMarkers(content, false);
const contentState = store.getters['contentState/current'];
options.content = content.text;
options.selectionStart = contentState.selectionStart;
options.selectionEnd = contentState.selectionEnd;
}
options.patchHandler = {
makePatches,
applyPatches,
reversePatches,
};
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 insertBeforeTocElt = this.tocElt.firstChild;
let previewHtml = '';
let loadingImages = [];
this.conversionCtx.htmlSectionDiff.forEach((item) => {
for (let i = 0; i < item[1].length; i += 1) {
const section = this.conversionCtx.sectionList[sectionIdx];
@ -218,9 +219,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
newSectionDescList.push(sectionDesc);
previewHtml += sectionDesc.html;
sectionIdx += 1;
insertBeforePreviewElt.classList.remove('modified');
insertBeforePreviewElt = insertBeforePreviewElt.nextSibling;
insertBeforeTocElt.classList.remove('modified');
insertBeforeTocElt = insertBeforeTocElt.nextSibling;
} else if (item[0] === -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
sectionPreviewElt = document.createElement('div');
sectionPreviewElt.className = 'cl-preview-section modified';
sectionPreviewElt.className = 'cl-preview-section';
sectionPreviewElt.innerHTML = html;
if (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);
}
extensionSvc.sectionPreview(sectionPreviewElt, this.options);
loadingImages = [
...loadingImages,
...Array.prototype.slice.call(sectionPreviewElt.getElementsByTagName('img')),
];
// Create TOC section element
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');
if (headingElt) {
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');
// Run preview async operations (image loading, mathjax...)
const loadingImages = this.previewElt.querySelectorAll('.cl-preview-section.modified img');
const loadedPromises = loadingImages.cl_map(imgElt => new Promise((resolve) => {
const loadedPromises = loadingImages.map(imgElt => new Promise((resolve) => {
if (!imgElt.src) {
resolve();
return;
@ -392,7 +394,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
const editorEndOffset = editorSvc.getEditorOffset(previewSelectionEndOffset);
if (editorStartOffset !== undefined && editorEndOffset !== undefined) {
editorEngineSvc.clEditor.selectionMgr.setSelectionStartEnd(
editorStartOffset, editorEndOffset, false);
editorStartOffset, editorEndOffset);
}
}
editorSvc.previewSelectionRange = range;

View File

@ -34,6 +34,10 @@ function groupHeadings(headings, level = 1) {
return result;
}
const containerElt = document.createElement('div');
containerElt.className = 'hidden-rendering-container';
document.body.appendChild(containerElt);
export default {
/**
* Apply the template to the file content
@ -41,7 +45,7 @@ export default {
applyTemplate(fileId, template = {
value: '{{{files.0.content.text}}}',
helpers: '',
}) {
}, pdf = false) {
const file = store.state.file.itemMap[fileId];
return localDbSvc.loadItem(`${fileId}/content`)
.then((content) => {
@ -51,19 +55,19 @@ export default {
const parsingCtx = markdownConversionSvc.parseSections(converter, content.text);
const conversionCtx = markdownConversionSvc.convert(parsingCtx);
const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join('');
const elt = document.createElement('div');
elt.innerHTML = html;
containerElt.innerHTML = html;
extensionSvc.sectionPreview(containerElt, options);
// Unwrap tables
elt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => {
containerElt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => {
while (wrapperElt.firstChild) {
wrapperElt.parentNode.appendChild(wrapperElt.firstChild);
wrapperElt.parentNode.insertBefore(wrapperElt.firstChild, wrapperElt.nextSibling);
}
wrapperElt.parentNode.removeChild(wrapperElt);
});
// 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,
anchor: headingElt.id,
level: parseInt(headingElt.tagName.slice(1), 10),
@ -71,17 +75,19 @@ export default {
}));
const toc = groupHeadings(headings);
const view = {
pdf,
files: [{
name: file.name,
content: {
text: content.text,
properties,
yamlProperties: content.properties,
html: elt.innerHTML,
html: containerElt.innerHTML,
toc,
},
}],
};
containerElt.innerHTML = '';
// Run template conversion in a Worker to prevent attacks from helpers
const worker = new TemplateWorker();
@ -111,8 +117,8 @@ export default {
exportToDisk(fileId, type, template) {
const file = store.state.file.itemMap[fileId];
return this.applyTemplate(fileId, template)
.then((res) => {
const blob = new Blob([res], {
.then((html) => {
const blob = new Blob([html], {
type: 'text/plain;charset=utf-8',
});
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 utils from './utils';
import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
const indexedDB = window.indexedDB;
const dbVersion = 1;
const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup;
if (!indexedDB) {
throw new Error('Your browser is not supported. Please upgrade to the latest version.');
if (exportBackup) {
location.hash = '';
}
const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
class Connection {
constructor() {
@ -328,6 +325,15 @@ localDbSvc.sync()
// Set the ready flag
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 (!store.getters['data/loginToken'] &&
(utils.lastOpened + utils.cleanTrashAfter < Date.now())
@ -338,6 +344,21 @@ localDbSvc.sync()
.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
store.watch(
() => store.getters['file/current'].id,

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import store from '../../store';
import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils';
import providerRegistry from './providerRegistry';
import utils from '../utils';
const savedSha = {};
@ -65,6 +66,55 @@ export default providerRegistry.register({
})
.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) {
return {
providerId: this.id,

View File

@ -19,6 +19,16 @@ const photosScopes = ['https://www.googleapis.com/auth/photos'];
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 {
request(token, options) {
return networkSvc.request({
@ -37,7 +47,7 @@ export default {
expiresOn: 0,
});
// Refresh token and retry
return this.refreshToken(token.scopes, token)
return this.refreshToken(token, token.scopes)
.then(refreshedToken => this.request(refreshedToken, options));
}
throw err;
@ -115,11 +125,12 @@ export default {
return networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId,
response_type: 'token',
scope: ['openid', ...scopes].join(' '), // Need openid for user info
response_type: 'token id_token',
scope: ['openid', ...scopes].join(' '),
hd: appsDomain,
login_hint: sub,
prompt: silent ? 'none' : null,
nonce: utils.uid(),
}, silent)
// Call the token info endpoint
.then(data => networkSvc.request({
@ -142,9 +153,11 @@ export default {
scopes,
accessToken: data.accessToken,
expiresOn: Date.now() + (data.expiresIn * 1000),
idToken: data.idToken,
sub: `${res.body.sub}`,
isLogin: !store.getters['data/loginToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isSponsor: false,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -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.
// Save flags
token.isLogin = existingToken.isLogin || token.isLogin;
token.isSponsor = existingToken.isSponsor;
token.isDrive = existingToken.isDrive || token.isDrive;
token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
// Save 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
store.dispatch('data/setGoogleToken', token);
return token;
}));
},
refreshToken(scopes, token) {
refreshToken(token, scopes = []) {
const sub = token.sub;
const lastToken = store.getters['data/googleTokens'][sub];
const mergedScopes = [...new Set([
@ -185,8 +212,13 @@ export default {
return Promise.resolve()
.then(() => {
if (mergedScopes.length === lastToken.scopes.length &&
lastToken.expiresOn > Date.now() + tokenExpirationMargin
if (
// If we already have permissions for the requested scopes
mergedScopes.length === lastToken.scopes.length &&
// And lastToken is not expired
lastToken.expiresOn > Date.now() + tokenExpirationMargin &&
// And in case of a login token, ID token is still valid
(!lastToken.isLogin || checkIdToken(lastToken.idToken))
) {
return lastToken;
}
@ -222,6 +254,16 @@ export default {
google = window.google;
});
},
getSponsorship(token) {
return this.refreshToken(token)
.then(refreshedToken => networkSvc.request({
method: 'GET',
url: 'userInfo',
params: {
idToken: refreshedToken.idToken,
},
}, true));
},
signin() {
return this.startOauth2(driveAppDataScopes);
},
@ -238,7 +280,7 @@ export default {
const result = {
changes: [],
};
return this.refreshToken(driveAppDataScopes, token)
return this.refreshToken(token, driveAppDataScopes)
.then((refreshedToken) => {
const getPage = (pageToken = '1') => this.request(refreshedToken, {
method: 'GET',
@ -262,25 +304,25 @@ export default {
});
},
uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token)
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
},
uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token)
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
},
downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token)
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
},
downloadAppDataFile(token, id) {
return this.refreshToken(driveAppDataScopes, token)
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
},
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
.then(ifNotTooLate(refreshedToken => this.request(refreshedToken, {
method: 'DELETE',
@ -290,7 +332,7 @@ export default {
uploadBlogger(
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(() => {
if (blogId) {
@ -356,7 +398,7 @@ export default {
openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
return this.loadClientScript()
.then(() => this.refreshToken(scopes, token))
.then(() => this.refreshToken(token, scopes))
.then(refreshedToken => new Promise((resolve) => {
let picker;
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) {
let needSyncRestart = needSyncRestartParam;
class SyncContext {
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)
.then(() => localDbSvc.loadItem(`${fileId}/content`)
.catch(() => {})) // Item may not exist if content has not been downloaded yet
.then(() => {
const getFile = () => store.state.file.itemMap[fileId];
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const downloadedLocations = {};
const errorLocations = {};
const isLocationSynced = (syncLocation) => {
const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
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 syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
@ -149,16 +171,21 @@ function syncFile(fileId, needSyncRestartParam = false) {
}
let result;
syncLocations.some((syncLocation) => {
if (!errorLocations[syncLocation.id] &&
(!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation))
) {
const provider = providerRegistry.providers[syncLocation.providerId];
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 token = provider.getToken(syncLocation);
result = provider && token && store.dispatch('queue/doWithLocation', {
location: syncLocation,
promise: provider.downloadContent(token, syncLocation)
.then((serverContent = null) => {
downloadedLocations[syncLocation.id] = true;
fileSyncContext.downloaded[syncLocation.id] = true;
const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
@ -192,7 +219,7 @@ function syncFile(fileId, needSyncRestartParam = false) {
})();
if (!mergedContent) {
errorLocations[syncLocation.id] = true;
fileSyncContext.errors[syncLocation.id] = true;
return null;
}
@ -274,9 +301,10 @@ function syncFile(fileId, needSyncRestartParam = false) {
}
// If content was just created, restart sync to create the file as well
const syncDataByItemId = store.getters['data/syncDataByItemId'];
if (!syncDataByItemId[fileId]) {
needSyncRestart = true;
if (provider === mainProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
syncContext.restart = true;
}
});
})
@ -286,7 +314,7 @@ function syncFile(fileId, needSyncRestartParam = false) {
}
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
errorLocations[syncLocation.id] = true;
fileSyncContext.errors[syncLocation.id] = true;
}),
})
.then(() => syncOneContentLocation());
@ -304,12 +332,10 @@ function syncFile(fileId, needSyncRestartParam = false) {
.then(() => {
throw err;
}))
.then(
() => needSyncRestart,
(err) => {
.catch((err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
return syncFile(fileId, needSyncRestart);
return syncFile(fileId, syncContext);
}
throw err;
});
@ -385,6 +411,7 @@ function syncDataItem(dataId) {
}
function sync() {
const syncContext = new SyncContext();
const mainToken = store.getters['data/loginToken'];
return mainProvider.getChanges(mainToken)
.then((changes) => {
@ -481,8 +508,12 @@ function sync() {
const loadedContent = store.state.content.itemMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId];
// Sync if item hash and syncData hash are inconsistent
if (!syncData || hash !== syncData.hash) {
if (
// 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('/');
}
return fileId;
@ -490,13 +521,13 @@ function sync() {
return fileId;
};
const syncNextFile = (needSyncRestartParam) => {
const syncNextFile = () => {
const fileId = getOneFileIdToSync();
if (!fileId) {
return needSyncRestartParam;
return null;
}
return syncFile(fileId, needSyncRestartParam)
.then(needSyncRestart => syncNextFile(needSyncRestart));
return syncFile(fileId, syncContext)
.then(() => syncNextFile());
};
return Promise.resolve()
@ -508,14 +539,14 @@ function sync() {
const currentFileId = store.getters['file/current'].id;
if (currentFileId) {
// Sync current file first
return syncFile(currentFileId)
.then(needSyncRestart => syncNextFile(needSyncRestart));
return syncFile(currentFileId, syncContext)
.then(() => syncNextFile());
}
return syncNextFile();
})
.then(
(needSyncRestart) => {
if (needSyncRestart) {
() => {
if (syncContext.restart) {
// Restart sync
return sync();
}

View File

@ -1,4 +1,5 @@
import yaml from 'js-yaml';
import '../libs/clunderscore';
import defaultProperties from '../data/defaultFileProperties.yml';
const workspaceId = 'main';
@ -157,4 +158,57 @@ export default {
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
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 content from './modules/content';
import file from './modules/file';
import findReplace from './modules/findReplace';
import folder from './modules/folder';
import publishLocation from './modules/publishLocation';
import syncLocation from './modules/syncLocation';
@ -26,6 +27,7 @@ const store = new Vuex.Store({
ready: false,
offline: false,
lastOfflineCheck: 0,
monetizeSponsor: false,
},
getters: {
allItemMap: (state) => {
@ -33,6 +35,10 @@ const store = new Vuex.Store({
utils.types.forEach(type => Object.assign(result, state[type].itemMap));
return result;
},
isSponsor: (state, getters) => {
const loginToken = getters['data/loginToken'];
return state.monetizeSponsor || (loginToken && loginToken.isSponsor);
},
},
mutations: {
setReady: (state) => {
@ -44,6 +50,12 @@ const store = new Vuex.Store({
updateLastOfflineCheck: (state) => {
state.lastOfflineCheck = Date.now();
},
setMonetizeSponsor: (state, value) => {
state.monetizeSponsor = value;
},
setGoogleSponsor: (state, value) => {
state.googleSponsor = value;
},
},
actions: {
setOffline: ({ state, commit, dispatch }, value) => {
@ -91,6 +103,7 @@ const store = new Vuex.Store({
syncedContent,
content,
file,
findReplace,
folder,
publishLocation,
syncLocation,

View File

@ -6,6 +6,7 @@ import defaultSettings from '../../data/defaultSettings.yml';
import defaultLocalSettings from '../../data/defaultLocalSettings';
import plainHtmlTemplate from '../../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../../data/styledHtmlTemplate.html';
import styledHtmlWithTocTemplate from '../../data/styledHtmlWithTocTemplate.html';
import jekyllSiteTemplate from '../../data/jekyllSiteTemplate.html';
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.toggleSidePreview = localSettingsToggler('showSidePreview');
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.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', {
sideBarPanel: value === undefined ? 'menu' : value,
});
@ -112,6 +135,7 @@ const additionalTemplates = {
plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
};
module.getters.allTemplates = (state, getters) => ({
@ -187,6 +211,10 @@ module.actions.setSyncData = setter('syncData');
module.getters.dataSyncData = getter('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
module.getters.tokens = getter('tokens');
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 previewButtonWidth = 55;
const editorTopPadding = 10;
@ -13,6 +12,7 @@ const maxTitleMaxWidth = 800;
const minTitleMaxWidth = 200;
const constants = {
editorMinWidth: 320,
explorerWidth: 250,
sideBarWidth: 280,
navigationBarHeight: 44,
@ -28,6 +28,7 @@ function computeStyles(state, localSettings, getters, styles = {
showPreview: localSettings.showSidePreview || !localSettings.showEditor,
showSideBar: localSettings.showSideBar,
showExplorer: localSettings.showExplorer,
layoutOverflow: false,
}) {
styles.innerHeight = state.bodyHeight;
if (styles.showNavigationBar) {
@ -46,14 +47,16 @@ function computeStyles(state, localSettings, getters, styles = {
}
let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth;
if (doublePanelWidth < editorMinWidth) {
doublePanelWidth = editorMinWidth;
styles.innerWidth = editorMinWidth + constants.buttonBarWidth;
if (doublePanelWidth < constants.editorMinWidth) {
doublePanelWidth = constants.editorMinWidth;
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.showPreview = false;
styles.layoutOverflow = false;
return computeStyles(state, localSettings, getters, styles);
}

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