Added keystrokes and shortcuts. Added templates

This commit is contained in:
Benoit Schweblin 2017-09-17 16:32:39 +01:00
parent cc2f9fa204
commit 0b0bec15e2
71 changed files with 2349 additions and 491 deletions

View File

@ -63,7 +63,7 @@ module.exports = {
}
},
{
test: /\.md$/,
test: /\.(md|yml|html)$/,
loader: 'raw-loader'
}
]

View File

@ -12,9 +12,13 @@
},
"dependencies": {
"bezier-easing": "^1.1.0",
"clipboard": "^1.7.1",
"clunderscore": "^1.0.3",
"diff-match-patch": "^1.0.0",
"file-saver": "^1.3.3",
"handlebars": "^4.0.10",
"indexeddbshim": "^3.0.4",
"js-yaml": "^3.9.1",
"katex": "^0.7.1",
"markdown-it": "^8.3.1",
"markdown-it-abbr": "^1.0.4",
@ -24,6 +28,7 @@
"markdown-it-pandoc-renderer": "1.1.3",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"mousetrap": "^1.6.1",
"normalize-scss": "^7.0.0",
"prismjs": "^1.6.0",
"raw-loader": "^0.5.1",
@ -79,7 +84,8 @@
"webpack-bundle-analyzer": "^2.2.1",
"webpack-dev-middleware": "^1.10.0",
"webpack-hot-middleware": "^2.18.0",
"webpack-merge": "^4.1.0"
"webpack-merge": "^4.1.0",
"worker-loader": "^0.8.1"
},
"engines": {
"node": ">= 4.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

@ -7,10 +7,18 @@
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import Layout from './Layout';
import Modal from './Modal';
// Global directives
Vue.directive('focus', {
inserted(el) {
el.focus();
},
});
export default {
components: {
Layout,
@ -24,7 +32,7 @@ export default {
return !this.$store.getters['content/current'].id;
},
showModal() {
return !!this.$store.state.modal.content;
return !!this.$store.state.modal.config;
},
},
};

View File

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

View File

@ -1,6 +1,6 @@
<template>
<div class="editor">
<pre class="editor__inner markdown-highlighting" :style="{padding: styles.editorPadding}"></pre>
<pre class="editor__inner markdown-highlighting" :style="{padding: styles.editorPadding}" :class="{monospaced: computedSettings.editor.monospacedFontOnly}"></pre>
</div>
</template>
@ -9,9 +9,14 @@
import { mapGetters } from 'vuex';
export default {
computed: mapGetters('layout', [
'styles',
]),
computed: {
...mapGetters('layout', [
'styles',
]),
...mapGetters('data', [
'computedSettings',
]),
},
};
</script>

View File

@ -24,13 +24,6 @@ import defaultContent from '../data/defaultContent.md';
export default {
name: 'explorer-node',
props: ['node', 'depth'],
directives: {
focus: {
inserted(el) {
el.focus();
},
},
},
data: () => ({
editingValue: '',
}),

View File

@ -0,0 +1,84 @@
<template>
<div class="modal__inner-1 modal__inner-1--file-properties">
<div class="modal__inner-2">
<div class="tabs">
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'">
Current file properties
</div>
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'">
Default properties
</div>
</div>
<div class="form-entry">
<label class="form-entry__label">YAML</label>
<div class="form-entry__field">
<code-editor v-if="tab === 'custom'" lang="yaml" :value="customProperties" key="custom-properties" @changed="setCustomProperties"></code-editor>
<code-editor v-else lang="yaml" :value="defaultProperties" disabled="true" key="default-properties"></code-editor>
</div>
</div>
<div class="modal__error modal__error--file-properties">{{error}}</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="!error && config.resolve(strippedCustomProperties)">Ok</button>
</div>
</div>
</div>
</template>
<script>
import yaml from 'js-yaml';
import { mapState } from 'vuex';
import CodeEditor from './CodeEditor';
import defaultProperties from '../data/defaultFileProperties.yml';
const emptyProperties = '# Add custom properties for the current file here to override the default properties.\n';
export default {
components: {
CodeEditor,
},
data: () => ({
tab: 'custom',
defaultProperties,
customProperties: null,
error: null,
}),
computed: {
...mapState('modal', [
'config',
]),
strippedCustomProperties() {
return this.customProperties === emptyProperties ? '\n' : this.customProperties.replace(/\t/g, ' ');
},
},
created() {
const properties = this.$store.getters['content/current'].properties;
this.setCustomProperties(properties === '\n' ? emptyProperties : properties);
},
methods: {
setCustomProperties(value) {
this.customProperties = value;
try {
yaml.safeLoad(this.strippedCustomProperties);
this.error = null;
} catch (e) {
this.error = e.message;
}
},
},
};
</script>
<style lang="scss">
@import 'common/variables.scss';
.modal__inner-1--file-properties {
max-width: 600px;
}
.modal__error--file-properties {
white-space: pre-wrap;
font-family: $font-family-monospace;
font-size: $font-size-monospace;
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div class="modal__inner-1 modal__inner-1--google-photo">
<div class="modal__inner-2">
<div class="google-photo__tumbnail" :style="{'background-image': thumbnailUrl}"></div>
<div class="form-entry">
<label class="form-entry__label" for="title">Title (optional)</label>
<div class="form-entry__field">
<input id="title" type="text" class="textfield" v-model.trim="title" @keyup.enter="resolve()">
</div>
</div>
<div class="form-entry">
<label class="form-entry__label" for="size">Size limit (optional)</label>
<div class="form-entry__field">
<input id="size" type="text" class="textfield" v-model="size" @keyup.enter="resolve()">
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
const makeThumbnail = (url, size) => `${url}=s${size}`;
export default {
data: () => ({
title: '',
size: '',
}),
computed: {
thumbnailUrl() {
return `url(${makeThumbnail(this.config.url, 320)})`;
},
...mapState('modal', [
'config',
]),
},
methods: {
resolve() {
let url = this.config.url;
const size = parseInt(this.size, 10);
if (!isNaN(size)) {
url = makeThumbnail(url, size);
}
if (this.title) {
url += ` "${this.title}"`;
}
const callback = this.config.callback;
this.config.resolve();
callback(url);
},
reject() {
const callback = this.config.callback;
this.config.reject();
callback(null);
},
},
};
</script>
<style lang="scss">
.google-photo__tumbnail {
height: 160px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<div class="modal__inner-1 modal__inner-1--html-export">
<div class="modal__inner-2">
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<select id="template" v-model="selectedTemplate" class="textfield">
<option v-for="(template, id) in allTemplates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</div>
<div class="modal__button-bar">
<button class="button button--copy">Copy to clipboard</button>
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Clipboard from 'clipboard';
import exportSvc from '../services/exportSvc';
export default {
data: () => ({
result: '',
}),
computed: {
...mapState('modal', [
'config',
]),
...mapGetters('data', [
'allTemplates',
]),
selectedTemplate: {
get() {
return this.$store.getters['data/localSettings'].htmlExportLastTemplate;
},
set(value) {
this.$store.dispatch('data/patchLocalSettings', {
htmlExportLastTemplate: value,
});
},
},
},
mounted() {
this.$watch('selectedTemplate', (selectedTemplate) => {
const currentFile = this.$store.getters['file/current'];
exportSvc.applyTemplate(currentFile.id, this.allTemplates[selectedTemplate])
.then((res) => {
this.result = res;
});
}, {
immediate: true,
});
this.clipboard = new Clipboard(this.$el.querySelector('.button--copy'), {
text: () => this.result,
});
},
destroyed() {
this.clipboard.destroy();
},
methods: {
configureTemplates() {
const reopen = () => this.$store.dispatch('modal/open', 'htmlExport');
this.$store.dispatch('modal/open', {
type: 'templates',
selectedKey: this.selectedTemplate,
})
.then(templates => this.$store.dispatch('data/setTemplates', templates))
.then(reopen, reopen);
},
resolve() {
const currentFile = this.$store.getters['file/current'];
exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplates[this.selectedTemplate]);
this.config.resolve();
},
},
};
</script>

View File

@ -0,0 +1,79 @@
<template>
<div class="modal__inner-1 modal__inner-1--image">
<div class="modal__inner-2">
<div class="form-entry">
<label class="form-entry__label" for="url">URL</label>
<div class="form-entry__field">
<input id="url" type="text" class="textfield" v-model="url" @keyup.enter="resolve()">
</div>
</div>
<menu-entry @click.native="openGooglePhotos(token)" v-for="token in googlePhotosTokens" :key="token.sub">
<icon-google-photos slot="icon"></icon-google-photos>
<div>Open from Google Photos</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="addGooglePhotosAccount">
<icon-google-photos slot="icon"></icon-google-photos>
<span>Add Google Photos account</span>
</menu-entry>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MenuEntry from './MenuEntry';
import googleHelper from '../services/providers/helpers/googleHelper';
export default {
components: {
MenuEntry,
},
data: () => ({
url: '',
}),
computed: {
...mapState('modal', [
'config',
]),
googlePhotosTokens() {
const googleToken = this.$store.getters['data/googleTokens'];
return Object.keys(googleToken)
.map(sub => googleToken[sub])
.filter(token => token.isPhotos)
.sort((token1, token2) => token1.name.localeCompare(token2.name));
},
},
methods: {
resolve() {
if (this.url) {
const callback = this.config.callback;
this.config.resolve();
callback(this.url);
}
},
reject() {
const callback = this.config.callback;
this.config.reject();
callback(null);
},
addGooglePhotosAccount() {
return googleHelper.addPhotosAccount();
},
openGooglePhotos(token) {
const callback = this.config.callback;
this.config.reject();
googleHelper.openPicker(token, 'img')
.then(res => res[0] && this.$store.dispatch('modal/open', {
type: 'googlePhoto',
url: res[0].url,
callback,
}));
},
},
};
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="modal__inner-1 modal__inner-1--link" @keyup.enter="resolve()">
<div class="modal__inner-2">
<div class="form-entry">
<label class="form-entry__label" for="url">URL</label>
<div class="form-entry__field">
<input id="url" type="text" class="textfield" v-model="url">
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
data: () => ({
url: '',
}),
computed: mapState('modal', [
'config',
]),
methods: {
resolve() {
if (this.url) {
const callback = this.config.callback;
this.config.resolve();
callback(this.url);
}
},
reject() {
const callback = this.config.callback;
this.config.reject();
callback(null);
},
},
};
</script>

View File

@ -1,18 +1,18 @@
<template>
<div class="side-bar-item button flex flex--row flex--align-center">
<div class="side-bar-item__icon flex flex--column flex--center">
<a href="javascript:void(0)" class="menu-entry button flex flex--row flex--align-center">
<div class="menu-entry__icon flex flex--column flex--center">
<slot name="icon"></slot>
</div>
<div class="flex flex--column">
<slot></slot>
</div>
</div>
</a>
</template>
<style lang="scss">
.side-bar-item {
.menu-entry {
text-align: left;
padding: 10px 12px;
padding: 10px;
height: auto;
span {
@ -24,7 +24,7 @@
}
}
.side-bar-item__icon {
.menu-entry__icon {
height: 20px;
width: 20px;
margin-right: 12px;

View File

@ -1,10 +1,18 @@
<template>
<div class="modal" v-focus @keyup.esc="setContent()">
<div class="modal__inner-1">
<div class="modal" @keyup.esc="onEscape">
<file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal>
<settings-modal v-else-if="config.type === 'settings'"></settings-modal>
<templates-modal v-else-if="config.type === 'templates'"></templates-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>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal>
<div v-else class="modal__inner-1">
<div class="modal__inner-2">
<div class="modal__content-text" v-html="content.text"></div>
<div class="modal__content" v-html="config.content"></div>
<div class="modal__button-bar">
<button v-for="button in content.buttons" :key="button.text" class="button" @click="button.onClick()">{{button.text}}</button>
<button v-if="config.rejectText" class="button" @click="config.reject()">{{config.rejectText}}</button>
<button v-if="config.resolveText" class="button" @click="config.resolve()">{{config.resolveText}}</button>
</div>
</div>
</div>
@ -13,27 +21,46 @@
<script>
import { mapState, mapMutations } from 'vuex';
import FilePropertiesModal from './FilePropertiesModal';
import SettingsModal from './SettingsModal';
import TemplatesModal from './TemplatesModal';
import LinkModal from './LinkModal';
import ImageModal from './ImageModal';
import GooglePhotoModal from './GooglePhotoModal';
import HtmlExportModal from './HtmlExportModal';
import editorEngineSvc from '../services/editorEngineSvc';
export default {
directives: {
focus: {
inserted(el) {
const eltToFocus = el.querySelector('input.text-input') || el.querySelector('button.button');
if (eltToFocus) {
eltToFocus.focus();
}
},
},
components: {
FilePropertiesModal,
SettingsModal,
TemplatesModal,
LinkModal,
ImageModal,
GooglePhotoModal,
HtmlExportModal,
},
computed: mapState('modal', [
'content',
'config',
]),
methods: {
...mapMutations('modal', [
'setContent',
'setConfig',
]),
hideOnExternalEvent(evt) {
if (this.content) {
onEscape() {
this.setConfig();
editorEngineSvc.clEditor.focus();
},
onFocusInOut(evt) {
const isFocusIn = evt.type === 'focusin';
if (evt.target.parentNode && evt.target.parentNode.parentNode) {
// Focus effect
if (evt.target.parentNode.classList.contains('form-entry__field') &&
evt.target.parentNode.parentNode.classList.contains('form-entry')) {
evt.target.parentNode.parentNode.classList.toggle('form-entry--focused', isFocusIn);
}
}
if (isFocusIn && this.config) {
const modalInner = this.$el.querySelector('.modal__inner-2');
let target = evt.target;
while (target) {
@ -42,15 +69,23 @@ export default {
}
target = target.parentNode;
}
this.setContent();
this.setConfig();
}
},
},
mounted() {
window.addEventListener('focusin', this.hideOnExternalEvent);
window.addEventListener('focusin', this.onFocusInOut);
window.addEventListener('focusout', this.onFocusInOut);
const eltToFocus = this.$el.querySelector('input.text-input')
|| this.$el.querySelector('.textfield')
|| this.$el.querySelector('button.button');
if (eltToFocus) {
eltToFocus.focus();
}
},
destroyed() {
window.removeEventListener('focusin', this.hideOnExternalEvent);
window.removeEventListener('focusin', this.onFocusInOut);
window.removeEventListener('focusout', this.onFocusInOut);
},
};
</script>
@ -62,22 +97,53 @@ export default {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(192, 192, 192, 0.8);
background-color: rgba(180, 180, 180, 0.75);
overflow: auto;
}
.modal__inner-1 {
margin: 0 auto;
display: table;
width: 100%;
min-width: 320px;
max-width: 500px;
}
.modal__inner-2 {
margin: 50px 10px;
margin: 50px 10px 100px;
background-color: #fff;
padding: 25px 50px;
padding: 40px 50px 30px;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: $border-radius-base;
width: 100%;
background-image: linear-gradient(to left, #ffe600, #ffe600 25%, #bbd500 25%, #bbd500 50%, #ff8a00 50%, #ff8a00 75%, #75b7fd 75%);
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: $border-radius-base;
width: 100%;
background-image: linear-gradient(to right, #ffe600, #ffe600 25%, #bbd500 25%, #bbd500 50%, #ff8a00 50%, #ff8a00 75%, #75b7fd 75%);
}
}
.modal__content :first-child {
margin-top: 0;
}
.modal__error {
color: #de2c00;
}
.modal__button-bar {

View File

@ -6,8 +6,8 @@
</button>
</div>
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
<button class="navigation-bar__button button" @click="toggleSideBar()">
<icon-menu></icon-menu>
<button class="navigation-bar__button navigation-bar__button--stackedit button" @click="toggleSideBar()">
<icon-stackedit></icon-stackedit>
</button>
</div>
<div class="navigation-bar__inner navigation-bar__inner--right flex flex--row">
@ -50,7 +50,7 @@
<icon-format-quote-close></icon-format-quote-close>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('code')">
<icon-code-braces></icon-code-braces>
<icon-code-tags></icon-code-tags>
</button>
<button class="navigation-bar__button button" @click="pagedownClick('link')">
<icon-link-variant></icon-link-variant>
@ -212,8 +212,19 @@ export default {
margin-bottom: 20px;
.navigation-bar__inner--button & {
padding: 7px;
padding: 6px;
width: 38px;
&.navigation-bar__button--stackedit {
opacity: 0.8;
padding: 4px;
&:active,
&:focus,
&:hover {
opacity: 1;
}
}
}
}

View File

@ -0,0 +1,84 @@
<template>
<div class="modal__inner-1 modal__inner-1--settings">
<div class="modal__inner-2">
<div class="tabs">
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'custom'}" @click="tab = 'custom'">
Custom settings
</div>
<div class="tabs__tab" :class="{'tabs__tab--active': tab === 'default'}" @click="tab = 'default'">
Default settings
</div>
</div>
<div class="form-entry">
<label class="form-entry__label">YAML</label>
<div class="form-entry__field">
<code-editor v-if="tab === 'custom'" lang="yaml" :value="customSettings" key="custom-settings" @changed="setCustomSettings"></code-editor>
<code-editor v-else lang="yaml" :value="defaultSettings" disabled="true" key="default-settings"></code-editor>
</div>
</div>
<div class="modal__error modal__error--settings">{{error}}</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
</div>
</div>
</div>
</template>
<script>
import yaml from 'js-yaml';
import { mapState } from 'vuex';
import CodeEditor from './CodeEditor';
import defaultSettings from '../data/defaultSettings.yml';
const emptySettings = '# Add your custom settings here to override the default settings.\n';
export default {
components: {
CodeEditor,
},
data: () => ({
tab: 'custom',
defaultSettings,
customSettings: null,
error: null,
}),
computed: {
...mapState('modal', [
'config',
]),
strippedCustomSettings() {
return this.customSettings === emptySettings ? '\n' : this.customSettings.replace(/\t/g, ' ');
},
},
created() {
const settings = this.$store.getters['data/settings'];
this.setCustomSettings(settings === '\n' ? emptySettings : settings);
},
methods: {
setCustomSettings(value) {
this.customSettings = value;
try {
yaml.safeLoad(this.strippedCustomSettings);
this.error = null;
} catch (e) {
this.error = e.message;
}
},
},
};
</script>
<style lang="scss">
@import 'common/variables.scss';
.modal__inner-1--settings {
max-width: 600px;
}
.modal__error--settings {
white-space: pre-wrap;
font-family: $font-family-monospace;
font-size: $font-size-monospace;
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="side-bar flex flex--column">
<div class="side-title flex flex--row">
<button v-if="panel !== 'menu'" class="side-title__button button" @click="panel = 'menu'">
<button v-if="panel !== 'menu'" class="side-title__button button" @click="setPanel('menu')">
<icon-arrow-left></icon-arrow-left>
</button>
<div class="side-title__title">
@ -12,30 +12,110 @@
</button>
</div>
<div class="side-bar__inner">
<!-- Main menu -->
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
<side-bar-item v-if="!loginToken" @click.native="signin">
<menu-entry v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in with Google</div>
<span>Have all your files and settings backed up and synced.</span>
</side-bar-item>
<!-- <side-bar-item @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in on CouchDB</div>
<span>Save and collaborate on a CouchDB hosted by you.</span>
</side-bar-item> -->
<span>Back up and sync all your files, folders and settings.</span>
</menu-entry>
<hr v-if="!loginToken">
<side-bar-item @click.native="panel = 'toc'">
<menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync>
<div>Synchronize</div>
<span>Open, save, collaborate in the Cloud.</span>
</menu-entry>
<menu-entry @click.native="setPanel('publish')">
<icon-upload slot="icon"></icon-upload>
<div>Publish</div>
<span>Export to the web.</span>
</menu-entry>
<hr>
<menu-entry @click.native="fileProperties">
<icon-view-list slot="icon"></icon-view-list>
<div>File properties</div>
<span>Add publication metadata and configure extensions.</span>
</menu-entry>
<menu-entry @click.native="setPanel('toc')">
<icon-toc slot="icon"></icon-toc>
Table of contents
</side-bar-item>
<side-bar-item @click.native="panel = 'help'">
</menu-entry>
<menu-entry @click.native="setPanel('help')">
<icon-help-circle slot="icon"></icon-help-circle>
Markdown cheat sheet
</side-bar-item>
</menu-entry>
<hr>
<menu-entry @click.native="importFile">
<icon-hard-disk slot="icon"></icon-hard-disk>
Import from disk
</menu-entry>
<menu-entry @click.native="setPanel('export')">
<icon-hard-disk slot="icon"></icon-hard-disk>
Export to disk
</menu-entry>
<hr>
<menu-entry @click.native="setPanel('more')">
More...
</menu-entry>
</div>
<!-- Sync menu -->
<div v-else-if="panel === 'sync'" class="side-bar__panel side-bar__panel--menu">
<div v-for="token in googleDriveTokens" :key="token.sub">
<menu-entry @click.native="openGoogleDrive(token)">
<icon-google-drive slot="icon"></icon-google-drive>
<div>Open from Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
<menu-entry @click.native="saveGoogleDrive(token)">
<icon-google-drive slot="icon"></icon-google-drive>
<div>Save on Google Drive</div>
<span>{{token.name}}</span>
</menu-entry>
<hr>
</div>
<menu-entry @click.native="addGoogleDriveAccount">
<icon-google-drive slot="icon"></icon-google-drive>
<span>Add Google Drive account</span>
</menu-entry>
</div>
<!-- More menu -->
<div v-else-if="panel === 'more'" class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="settings">
<icon-settings slot="icon"></icon-settings>
<div>Settings</div>
<span>Tweak application and keyboard shortcuts.</span>
</menu-entry>
<menu-entry @click.native="templates">
<icon-code-braces slot="icon"></icon-code-braces>
<div>Templates</div>
<span>Configure Handlebars templates for your exports.</span>
</menu-entry>
<menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout>
<div>Reset application</div>
<span>Sign out and clean local data.</span>
</menu-entry>
</div>
<!-- Export menu -->
<div v-else-if="panel === 'export'" class="side-bar__panel side-bar__panel--menu">
<menu-entry @click.native="exportMarkdown">
<icon-download slot="icon"></icon-download>
Export as Markdown
</menu-entry>
<menu-entry @click.native="exportHtml">
<icon-download slot="icon"></icon-download>
Export as HTML
</menu-entry>
<menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download>
Export as PDF
</menu-entry>
</div>
<!-- Help -->
<div v-else-if="panel === 'help'" class="side-bar__panel side-bar__panel--help">
<pre class="markdown-highlighting" v-html="markdownSample"></pre>
</div>
<!-- TOC -->
<div class="side-bar__panel side-bar__panel--toc" :class="{'side-bar__panel--hidden': panel !== 'toc'}">
<toc>
</toc>
@ -47,49 +127,98 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import Toc from './Toc';
import SideBarItem from './SideBarItem';
import MenuEntry from './MenuEntry';
import markdownSample from '../data/markdownSample.md';
import markdownConversionSvc from '../services/markdownConversionSvc';
import googleHelper from '../services/providers/helpers/googleHelper';
import googleDriveProvider from '../services/providers/googleDriveProvider';
import syncSvc from '../services/syncSvc';
import localDbSvc from '../services/localDbSvc';
import exportSvc from '../services/exportSvc';
const panelNames = {
menu: 'Menu',
help: 'Markdown cheat sheet',
toc: 'Table of contents',
sync: 'Synchronize',
publish: 'Publish',
export: 'Export to disk',
more: 'More',
};
export default {
components: {
Toc,
SideBarItem,
MenuEntry,
},
data: () => ({
panel: 'menu',
panelNames: {
menu: 'Menu',
toc: 'Table of Contents',
help: 'Markdown cheat sheet',
},
markdownSample: markdownConversionSvc.highlight(markdownSample),
}),
computed: {
...mapGetters('data', [
'loginToken',
]),
panel() {
return this.$store.getters['data/localSettings'].sideBarPanel;
},
panelName() {
return panelNames[this.panel];
},
googleDriveTokens() {
const googleToken = this.$store.getters['data/googleTokens'];
return Object.keys(googleToken)
.map(sub => googleToken[sub])
.filter(token => token.isDrive)
.sort((token1, token2) => token1.name.localeCompare(token2.name));
},
},
methods: {
...mapActions('data', [
'toggleSideBar',
]),
...mapActions('data', {
setPanel: 'setSideBarPanel',
}),
signin() {
googleHelper.startOauth2([
'openid',
'https://www.googleapis.com/auth/drive.appdata',
]).then(() => syncSvc.requestSync());
return googleHelper.signin()
.then(() => syncSvc.requestSync());
},
importFile() {
return this.$store.dispatch('modal/notImplemented');
},
fileProperties() {
return this.$store.dispatch('modal/open', 'fileProperties')
.then(properties => this.$store.dispatch('content/patchCurrent', { properties }));
},
settings() {
return this.$store.dispatch('modal/open', 'settings')
.then(settings => this.$store.dispatch('data/setSettings', settings));
},
templates() {
return this.$store.dispatch('modal/open', 'templates')
.then(templates => this.$store.dispatch('data/setTemplates', templates));
},
reset() {
return this.$store.dispatch('modal/reset')
.then(() => localDbSvc.removeDb());
},
addGoogleDriveAccount() {
return googleHelper.addGoogleDriveAccount();
},
openGoogleDrive(token) {
return googleHelper.openPicker(token, 'doc')
.then(files => this.$store.dispatch('queue/enqueue',
() => googleDriveProvider.openFiles(token, files)));
},
exportMarkdown() {
const currentFile = this.$store.getters['file/current'];
return exportSvc.exportToDisk(currentFile.id, 'md');
},
exportHtml() {
return this.$store.dispatch('modal/open', 'htmlExport');
},
exportPdf() {
return this.$store.dispatch('modal/notImplemented');
},
},
};
@ -103,7 +232,7 @@ export default {
height: 100%;
hr {
margin: 5px 10px;
margin: 10px;
}
}
@ -124,7 +253,7 @@ export default {
}
.side-bar__panel--menu {
padding: 5px;
padding: 10px;
}
.side-bar__panel--help {

View File

@ -0,0 +1,156 @@
<template>
<div class="modal__inner-1 modal__inner-1--templates">
<div class="modal__inner-2">
<div class="form-entry">
<label class="form-entry__label" for="template">Template</label>
<div class="form-entry__field">
<input v-if="isEditing" id="template" type="text" class="textfield" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc.stop="submitEdit(true)" v-model="editingName">
<select v-else id="template" v-model="selectedId" class="textfield">
<option v-for="(template, id) in templates" :key="id" v-bind:value="id">
{{ template.name }}
</option>
</select>
</div>
<div class="form-entry__actions flex flex--row flex--end">
<button class="form-entry__button button" @click="create">
<icon-file-plus></icon-file-plus>
</button>
<button class="form-entry__button button" @click="copy">
<icon-file-multiple></icon-file-multiple>
</button>
<button v-if="!isReadOnly" class="form-entry__button button" @click="isEditing = true">
<icon-pen></icon-pen>
</button>
<button v-if="!isReadOnly" class="form-entry__button button" @click="remove">
<icon-delete></icon-delete>
</button>
</div>
</div>
<div class="form-entry">
<label class="form-entry__label">Value</label>
<div class="form-entry__field" v-for="(template, id) in templates" :key="id" v-if="id === selectedId">
<code-editor lang="handlebars" :value="template.value" :disabled="isReadOnly" @changed="template.value = $event"></code-editor>
</div>
</div>
<div v-if="!isReadOnly">
<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>
<div class="form-entry__field" v-for="(template, id) in templates" :key="id" v-if="id === selectedId">
<code-editor lang="javascript" :value="template.helpers" @changed="template.helpers = $event"></code-editor>
</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>
</template>
<script>
import { mapState } from 'vuex';
import utils from '../services/utils';
import CodeEditor from './CodeEditor';
import emptyTemplateValue from '../data/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../data/emptyTemplateHelpers.js'; // eslint-disable-line
function fillEmptyFields(template) {
if (template.value === '\n') {
template.value = emptyTemplateValue;
}
if (template.helpers === '\n') {
template.helpers = emptyTemplateHelpers;
}
}
export default {
components: {
CodeEditor,
},
data: () => ({
selectedId: '',
templates: {},
showHelpers: false,
isEditing: false,
editingName: '',
}),
computed: {
...mapState('modal', [
'config',
]),
isReadOnly() {
return this.templates[this.selectedId].isAdditional;
},
},
created() {
this.$watch(
() => this.$store.getters['data/allTemplates'],
(allTemplates) => {
const templates = utils.sortObject(
utils.deepCopy(allTemplates),
(key, template) => template.name,
);
Object.keys(templates).forEach(id => fillEmptyFields(templates[id]));
this.templates = templates;
this.selectedId = this.$store.state.modal.config.selectedId;
if (!templates[this.selectedId]) {
this.selectedId = Object.keys(templates)[0];
}
this.isEditing = false;
}, { immediate: true });
this.$watch('selectedId', (selectedId) => {
const template = this.templates[selectedId];
this.showHelpers = template.helpers !== emptyTemplateHelpers;
this.editingName = template.name;
}, { immediate: true });
},
methods: {
create() {
const template = {
name: 'New template',
value: '\n',
helpers: '\n',
};
fillEmptyFields(template);
this.selectedId = utils.uid();
this.templates[this.selectedId] = template;
this.isEditing = true;
},
copy() {
const template = utils.deepCopy(this.templates[this.selectedId]);
template.name += ' copy';
delete template.isAdditional;
this.selectedId = utils.uid();
this.templates[this.selectedId] = template;
this.isEditing = true;
},
remove() {
delete this.templates[this.selectedId];
this.selectedId = Object.keys(this.templates)[0];
},
submitEdit(cancel) {
const template = this.templates[this.selectedId];
if (!cancel && this.editingName) {
template.name = this.editingName.slice(0, 250);
} else {
this.editingName = template.name;
}
setTimeout(() => { // For the form-entry to get the blur event
this.isEditing = false;
}, 1);
},
resolve() {
this.config.resolve(this.templates);
},
},
};
</script>
<style lang="scss">
.modal__inner-1--templates {
max-width: 720px;
}
</style>

View File

@ -59,7 +59,7 @@ export default {
color: rgba(0, 0, 0, 0.75);
cursor: pointer;
font-size: 10px;
padding: 5px 20px 40px;
padding: 10px 20px 40px;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
@ -75,6 +75,7 @@ export default {
* {
margin: 0.2em 0;
padding: 0.2em 0;
border-bottom: 0;
}
h2 {

View File

@ -80,8 +80,9 @@ textarea {
&:focus,
&:hover {
color: #333;
background-color: rgba(0, 0, 0, 0.1);
background-color: rgba(0, 0, 0, 0.067);
outline: 0;
text-decoration: none;
}
&[disabled] {
@ -96,6 +97,76 @@ textarea {
}
}
.form-entry {
margin: 1em 0;
}
.form-entry__label {
display: block;
font-size: 0.9rem;
color: #a0a0a0;
.form-entry--focused & {
color: darken($link-color, 10%);
}
}
.form-entry__field {
border: 1px solid #d8d8d8;
border-radius: $border-radius-base;
position: relative;
overflow: hidden;
.form-entry--focused & {
border-color: $link-color;
}
}
.form-entry__actions {
text-align: right;
margin: 0.25em;
}
.form-entry__button {
width: 38px;
height: 38px;
padding: 6px;
display: inline-block;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
.textfield {
background-color: transparent;
border: 0;
font-family: inherit;
font-weight: 400;
font-size: 1.05em;
padding: 0 0.6rem;
box-sizing: border-box;
width: 100%;
max-width: 100%;
color: inherit;
height: 2.6rem;
&:focus {
outline: none;
}
&[disabled] {
cursor: not-allowed;
background-color: #f8f8f8;
color: #999;
}
}
.flex {
display: -webkit-box;
display: -webkit-flex;
@ -162,6 +233,7 @@ textarea {
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
@ -173,3 +245,44 @@ textarea {
text-overflow: ellipsis;
width: 100%;
}
.tabs {
border-bottom: 1px solid $hr-color;
margin-bottom: 2em;
&::after {
content: '';
display: block;
clear: both;
}
}
.tabs__tab {
width: 50%;
float: left;
text-align: center;
line-height: 2.5em;
cursor: pointer;
border-bottom: 2px solid transparent;
border-top-left-radius: $border-radius-base;
border-top-right-radius: $border-radius-base;
color: $link-color;
font-weight: 400;
font-size: 1.1em;
&:hover,
&:focus {
background-color: rgba(0, 0, 0, 0.067);
}
}
.tabs__tab--active {
border-bottom: 2px solid $link-color;
color: inherit;
cursor: auto;
&:hover,
&:focus {
background-color: transparent;
}
}

View File

@ -5,7 +5,7 @@ $line-height-title: 1.33;
$font-size-monospace: 0.85em;
$code-bg: rgba(0, 0, 0, 0.05);
$code-border-radius: 2px;
$link-color: #4a80cf;
$link-color: #0c93e4;
$border-radius-base: 2px;
$hr-color: rgba(128, 128, 128, 0.2);
$navbar-color: rgba(255, 255, 255, 0.67);

View File

@ -0,0 +1,25 @@
### File properties can contain metadata used for your publications (Wordpress, Blogger...).
### For example, you can specify a blog post title:
#title: My article
### Extension configuration
extensions:
# Markdown extensions
markdown:
abbr: true
breaks: false
deflist: true
del: true
fence: true
footnote: true
linkify: true
sub: true
sup: true
table: true
typographer: true
# Katex extension
katex:
enabled: true

View File

@ -6,4 +6,6 @@ export default () => ({
showSideBar: false,
showExplorer: false,
focusMode: false,
sideBarPanel: 'menu',
htmlExportLastTemplate: 'styledHtml',
});

View File

@ -0,0 +1,81 @@
# Adjust font size in editor and preview
fontSizeFactor: 1
# Adjust maximum text width in editor and preview
maxWidthFactor: 1
# Synchronize editor and preview scrollbars
scrollSync: true
# Editor settings
editor:
# Display images in the editor
inlineImages: true
# Use monospaced font only
monospacedFontOnly: false
# Keyboard shortcuts (see https://craig.is/killing/mice)
shortcuts:
-
keys: mod+s
method: sync
-
keys: mod+shift+b
method: bold
-
keys: mod+shift+i
method: italic
-
keys: mod+shift+l
method: link
-
keys: mod+shift+l
method: link
-
keys: mod+shift+q
method: quote
-
keys: mod+shift+k
method: code
-
keys: mod+shift+g
method: image
-
keys: mod+shift+o
method: olist
-
keys: mod+shift+o
method: olist
-
keys: mod+shift+u
method: ulist
-
keys: mod+shift+h
method: heading
-
keys: mod+shift+r
method: hr
-
keys: = = > space
method: expand
params:
- '==> '
- '⇒ '
-
keys: < = = space
method: expand
params:
- '<== '
- '⇐ '
# Default content for newly created files
newFileContent: |
> Written with [StackEdit](https://stackedit.io/).
# Default properties for newly created files
newFileProperties: |
# extensions:
# markdown:
# breaks: true

View File

@ -1,6 +1,7 @@
export default () => ({
id: null,
type: 'syncLocation',
provider: null,
fileId: null,
hash: 0,
});

View File

@ -0,0 +1,10 @@
/* Add your custom Handlebars helpers here.
For example:
Handlebars.registerHelper('transform', function (options) {
var result = options.fn(this);
return new Handlebars.SafeString(
result.replace(/<pre[^>]*>/g, '<pre class="prettyprint">')
);
});
*/

View File

@ -0,0 +1,24 @@
<!-- Specify your Handlebars template here.
The following JavaScript context will be passed to the template:
{
files: [{
name: 'The filename',
content: {
text: 'The file content',
html: '<p>The file content</p>',
yamlProperties: 'The file properties in YAML format',
properties: {
// Computed file properties object
},
toc: [
// Table Of Contents tree
]
}
}]
}
You can use Handlebars built-in helpers and some custom StackEdit helpers:
- {{#tocToHtml files.0.content.toc}}{{/tocToHtml}} will produce a nice TOC.
- {{#tocToHtml files.0.content.toc 3}}{{/tocToHtml}} will limit the TOC depth to 3.
-->

View File

@ -0,0 +1 @@
{{{files.0.content.html}}}

View File

@ -0,0 +1,16 @@
<!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="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>
</head>
<body>
<div class="export-container">{{{files.0.content.html}}}</div>
</body>
</html>

View File

@ -58,7 +58,7 @@ function texMath(state, silent) {
}
extensionSvc.onGetOptions((options, properties) => {
options.math = properties['ext:katex'] !== 'false';
options.math = properties.extensions.katex.enabled;
});
extensionSvc.onInitConverter(2, (markdown, options) => {

View File

@ -47,19 +47,8 @@ const inlineBaseRules2 = [
'text_collapse',
];
extensionSvc.onGetOptions((options, properties) => {
options.abbr = properties['ext:markdown:abbr'] !== 'false';
options.breaks = properties['ext:markdown:breaks'] === 'true';
options.deflist = properties['ext:markdown:deflist'] !== 'false';
options.del = properties['ext:markdown:del'] !== 'false';
options.fence = properties['ext:markdown:fence'] !== 'false';
options.footnote = properties['ext:markdown:footnote'] !== 'false';
options.linkify = properties['ext:markdown:linkify'] !== 'false';
options.sub = properties['ext:markdown:sub'] !== 'false';
options.sup = properties['ext:markdown:sup'] !== 'false';
options.table = properties['ext:markdown:table'] !== 'false';
options.typographer = properties['ext:markdown:typographer'] !== 'false';
});
extensionSvc.onGetOptions(
(options, properties) => Object.assign(options, properties.extensions.markdown));
extensionSvc.onInitConverter(0, (markdown, options) => {
markdown.set({

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="2.00 2.00 20.00 20.00">
<path fill="#0D0E0E" fill-opacity="1" stroke-linejoin="round" d="M 14.6,16.6L 19.2,12L 14.6,7.4L 16,6L 22,12L 16,18L 14.6,16.6 Z M 9.4,16.6L 4.8,12L 9.4,7.4L 8,6L 2,12L 8,18L 9.4,16.6 Z "/>
</svg>
</template>

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 4.9994,19.9981L 18.9994,19.9981L 18.9994,17.9981L 4.9994,17.9981M 18.9994,8.99807L 14.9994,8.99807L 14.9994,2.99807L 8.9994,2.99807L 8.9994,8.99807L 4.9994,8.99807L 11.9994,15.9981L 18.9994,8.99807 Z "/>
</svg>
</template>

View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M14.63,7.617l4.821,0l-4.821,-4.821l0,4.821Zm-6.136,-6.136l7.012,0l5.259,5.26l0,10.518c0,0.968 -0.785,1.753 -1.753,1.753l-10.527,0c-0.968,0 -1.744,-0.785 -1.744,-1.753l0.009,-14.024c0,-0.968 0.775,-1.754 1.744,-1.754Zm-3.506,3.507l0,15.777l14.024,0l0,1.754l-14.024,0c-0.965,0 -1.753,-0.789 -1.753,-1.754l0,-15.777l1.753,0Z"/>
</svg>
</template>

View File

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

View File

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

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-linejoin="round" d="M 6,2L 18,2C 19.1046,2 20,2.89543 20,4L 20,20C 20,21.1046 19.1046,22 18,22L 6,22C 4.89543,22 4,21.1046 4,20L 4,4C 4,2.89543 4.89543,2 6,2 Z M 12,4.00001C 8.68629,4.00001 5.99999,6.6863 5.99999,10C 5.99999,13.3137 8.68629,16 12.1022,15.9992L 11.2221,13.7674C 10.946,13.2891 11.1099,12.6775 11.5882,12.4013L 12.4542,11.9013C 12.9325,11.6252 13.5441,11.7891 13.8202,12.2674L 15.7446,14.6884C 17.1194,13.5889 18,11.8973 18,10C 18,6.6863 15.3137,4.00001 12,4.00001 Z M 12,9.00001C 12.5523,9.00001 13,9.44773 13,10C 13,10.5523 12.5523,11 12,11C 11.4477,11 11,10.5523 11,10C 11,9.44773 11.4477,9.00001 12,9.00001 Z M 7,18C 6.44771,18 6,18.4477 6,19C 6,19.5523 6.44771,20 7,20C 7.55228,20 8,19.5523 8,19C 8,18.4477 7.55228,18 7,18 Z M 12.0882,13.2674L 14.5757,19.5759L 17.1738,18.0759L 12.9542,12.7674L 12.0882,13.2674 Z "/>
</svg>
</template>

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 16.9999,17.25L 16.9999,14L 9.99998,14L 9.99998,10L 16.9999,10L 16.9999,6.75L 22.2499,12L 16.9999,17.25 Z M 13,2.00002C 14.1046,2.00002 15,2.89545 15,4.00002L 15,8L 13,8L 13,4.00002L 4,4.00004L 4,20L 13,20L 13,16L 15,16L 15,20C 15,21.1046 14.1046,22 13,22L 4,22C 2.89543,22 2,21.1046 2,20L 2,4.00004C 2,2.89547 2.89543,2.00006 4,2.00006L 13,2.00002 Z "/>
</svg>
</template>

View File

@ -1,5 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 3,6L 21,6L 21,8L 3,8L 3,6 Z M 3,11L 21,11L 21,13L 3,13L 3,11 Z M 3,16L 21,16L 21,18L 3,18L 3,16 Z "/>
</svg>
</template>

19
src/icons/Stackedit.vue Normal file
View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 126 126">
<path d="M103.5,0c3.762,0.003 7.395,0.971 10.725,2.716c0.966,0.506 1.808,1.221 2.712,1.831l-53.937,40.453l-53.937,-40.453c4.402,-3.289 8.02,-4.273 13.437,-4.547l81,0Z" style="fill:#ffe600;fill-rule:nonzero;"/>
<path d="M9.063,4.547l53.937,40.453l-58.04,72.55c-3.44,-4.417 -4.681,-8.528 -4.96,-14.05l0,-81c0.064,-5.221 1.801,-10.265 5.138,-14.312c0.914,-1.109 2.033,-2.033 3.05,-3.05l0.875,-0.591Z" style="fill:#bbd500;fill-rule:nonzero;"/>
<path d="M63,45l58.04,72.549l-0.178,0.263c-4.901,5.465 -10.068,7.82 -17.362,8.188l-81,0c-5.221,-0.065 -10.265,-1.801 -14.312,-5.138c-1.109,-0.915 -2.033,-2.034 -3.05,-3.05l-0.177,-0.262l58.039,-72.55Z" style="fill:#ff8a00;fill-rule:nonzero;"/>
<path d="M116.937,4.547c3.844,2.631 6.684,6.83 8.051,11.262c0.441,1.427 0.673,2.914 0.896,4.391c0.114,0.759 0.077,1.533 0.116,2.3l0,81c-0.023,3.748 -0.939,7.415 -2.716,10.725c-0.632,1.178 -1.496,2.216 -2.245,3.325l-58.039,-72.55l53.937,-40.453Z" style="fill:#75b7fd;fill-rule:nonzero;"/>
<path d="M32.063,12l61.874,0c7.767,0 14.063,6.296 14.063,14.063l0,61.874c0,7.767 -6.296,14.063 -14.063,14.063l-61.875,0c-7.766,0 -14.062,-6.296 -14.062,-14.063l0,-61.875c0,-7.766 6.296,-14.062 14.062,-14.062Z" style="fill:#fff;fill-rule:nonzero;"/>
<g>
<g>
<rect x="49.927" y="23.501" width="8.137" height="58.668" style="fill:#737373;"/>
<rect x="67.937" y="23.501" width="8.137" height="58.668" style="fill:#737373;"/>
</g>
<g>
<path d="M84.047,46.623l0,-10.239l-42.094,0l0,10.239l42.094,0Z" style="fill:#737373;"/>
<path d="M84.047,69.287l0,-10.24l-42.094,0l0,10.24l42.094,0Z" style="fill:#737373;"/>
</g>
</g>
</svg>
</template>

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-width="0.2" stroke-linejoin="round" d="M 8.99939,15.998L 8.99939,9.99805L 4.99939,9.99805L 11.9994,2.99805L 18.9994,9.99805L 14.9994,9.99805L 14.9994,15.998L 8.99939,15.998 Z M 4.99937,19.9981L 4.99937,17.9981L 18.9994,17.9981L 18.9994,19.9981L 4.99937,19.9981 Z "/>
</svg>
</template>

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path fill="#000000" fill-opacity="1" stroke-linejoin="round" d="M 9,5L 9,9L 21,9L 21,5M 9,19L 21,19L 21,15L 9,15M 9,14L 21,14L 21,10L 9,10M 4,9L 8,9L 8,5L 4,5M 4,19L 8,19L 8,15L 4,15M 4,14L 8,14L 8,10L 4,10L 4,14 Z "/>
</svg>
</template>

View File

@ -2,7 +2,6 @@ import Vue from 'vue';
import FormatBold from './FormatBold';
import FormatItalic from './FormatItalic';
import FormatQuoteClose from './FormatQuoteClose';
import CodeBraces from './CodeBraces';
import LinkVariant from './LinkVariant';
import FileImage from './FileImage';
import Table from './Table';
@ -15,9 +14,9 @@ import StatusBar from './StatusBar';
import NavigationBar from './NavigationBar';
import SidePreview from './SidePreview';
import Eye from './Eye';
import Menu from './Menu';
import Settings from './Settings';
import FilePlus from './FilePlus';
import FileMultiple from './FileMultiple';
import FolderPlus from './FolderPlus';
import Delete from './Delete';
import Close from './Close';
@ -28,13 +27,23 @@ import ArrowLeft from './ArrowLeft';
import HelpCircle from './HelpCircle';
import Toc from './Toc';
import Login from './Login';
import Logout from './Logout';
import Sync from './Sync';
import SyncOff from './SyncOff';
import Upload from './Upload';
import ViewList from './ViewList';
import HardDisk from './HardDisk';
import Download from './Download';
import CodeTags from './CodeTags';
import CodeBraces from './CodeBraces';
// Providers
import Stackedit from './Stackedit';
import GoogleDrive from './GoogleDrive';
import GooglePhotos from './GooglePhotos';
Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic);
Vue.component('iconFormatQuoteClose', FormatQuoteClose);
Vue.component('iconCodeBraces', CodeBraces);
Vue.component('iconLinkVariant', LinkVariant);
Vue.component('iconFileImage', FileImage);
Vue.component('iconTable', Table);
@ -47,9 +56,9 @@ Vue.component('iconStatusBar', StatusBar);
Vue.component('iconNavigationBar', NavigationBar);
Vue.component('iconSidePreview', SidePreview);
Vue.component('iconEye', Eye);
Vue.component('iconMenu', Menu);
Vue.component('iconSettings', Settings);
Vue.component('iconFilePlus', FilePlus);
Vue.component('iconFileMultiple', FileMultiple);
Vue.component('iconFolderPlus', FolderPlus);
Vue.component('iconDelete', Delete);
Vue.component('iconClose', Close);
@ -60,5 +69,16 @@ Vue.component('iconArrowLeft', ArrowLeft);
Vue.component('iconHelpCircle', HelpCircle);
Vue.component('iconToc', Toc);
Vue.component('iconLogin', Login);
Vue.component('iconLogout', Logout);
Vue.component('iconSync', Sync);
Vue.component('iconSyncOff', SyncOff);
Vue.component('iconUpload', Upload);
Vue.component('iconViewList', ViewList);
Vue.component('iconHardDisk', HardDisk);
Vue.component('iconDownload', Download);
Vue.component('iconCodeTags', CodeTags);
Vue.component('iconCodeBraces', CodeBraces);
// Providers
Vue.component('iconStackedit', Stackedit);
Vue.component('iconGoogleDrive', GoogleDrive);
Vue.component('iconGooglePhotos', GooglePhotos);

View File

@ -51,6 +51,8 @@ function SelectionMgr(editor) {
if (adjustScroll) {
var adjustment = scrollElt.clientHeight / 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
if (scrollElt.scrollTop > minScrollTop) {

View File

@ -139,7 +139,13 @@ class Animation {
this.$end.endCb = typeof endCb === 'function' && endCb;
this.$end.stepCb = typeof stepCb === 'function' && stepCb;
this.$startTime = Date.now() + this.$end.delay;
this.loop(this.$end.duration && useTransition);
if (!this.$end.duration) {
this.loop(false);
} else if (useTransition) {
this.loop(true);
} else {
this.$requestId = window.requestAnimationFrame(() => this.loop(false));
}
return this.elt;
}

View File

@ -28,7 +28,6 @@ const allowDebounce = (action, wait) => {
};
const diffMatchPatch = new DiffMatchPatch();
let lastContentId = null;
let instantPreview = true;
let tokens;
const anchorHash = {};
@ -153,8 +152,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
*/
initPrism() {
const options = {
insideFences: markdownConversionSvc.defaultOptions.insideFences,
...this.options,
insideFences: markdownConversionSvc.defaultOptions.insideFences,
};
this.prismGrammars = markdownGrammarSvc.makeGrammars(options);
},
@ -185,10 +184,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
},
};
editorEngineSvc.initClEditor(options);
editorEngineSvc.clEditor.toggleEditable(true);
const contentId = store.getters['content/current'].id;
// Switch off the editor when no content is loaded
editorEngineSvc.clEditor.toggleEditable(!!contentId);
this.restoreScrollPosition();
},
@ -213,7 +208,6 @@ 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 heading;
this.conversionCtx.htmlSectionDiff.forEach((item) => {
for (let i = 0; i < item[1].length; i += 1) {
const section = this.conversionCtx.sectionList[sectionIdx];
@ -237,13 +231,13 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
insertBeforeTocElt = insertBeforeTocElt.nextSibling;
this.tocElt.removeChild(sectionTocElt);
} else if (item[0] === 1) {
const html = this.conversionCtx.htmlSectionList[sectionIdx];
const html = htmlSanitizer.sanitizeHtml(this.conversionCtx.htmlSectionList[sectionIdx]);
sectionIdx += 1;
// Create preview section element
sectionPreviewElt = document.createElement('div');
sectionPreviewElt.className = 'cl-preview-section modified';
sectionPreviewElt.innerHTML = htmlSanitizer.sanitizeHtml(html);
sectionPreviewElt.innerHTML = html;
if (insertBeforePreviewElt) {
this.previewElt.insertBefore(sectionPreviewElt, insertBeforePreviewElt);
} else {
@ -254,17 +248,11 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// Create TOC section element
sectionTocElt = document.createElement('div');
sectionTocElt.className = 'cl-toc-section modified';
let headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6');
heading = undefined;
const headingElt = sectionPreviewElt.querySelector('h1, h2, h3, h4, h5, h6');
if (headingElt) {
heading = {
title: headingElt.textContent,
anchor: headingElt.id,
level: parseInt(headingElt.tagName.slice(1), 10),
};
headingElt = headingElt.cloneNode(true);
headingElt.removeAttribute('id');
sectionTocElt.appendChild(headingElt);
const clonedElt = headingElt.cloneNode(true);
clonedElt.removeAttribute('id');
sectionTocElt.appendChild(clonedElt);
}
if (insertBeforeTocElt) {
this.tocElt.insertBefore(sectionTocElt, insertBeforeTocElt);
@ -272,23 +260,13 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.tocElt.appendChild(sectionTocElt);
}
const clonedElt = sectionPreviewElt.cloneNode(true);
// Unwrap tables
clonedElt.querySelectorAll('.table-wrapper').cl_each((elt) => {
while (elt.firstChild) {
elt.parentNode.appendChild(elt.firstChild);
}
elt.parentNode.removeChild(elt);
});
previewHtml += clonedElt.innerHTML;
previewHtml += html;
newSectionDescList.push({
section,
editorElt: section.elt,
previewElt: sectionPreviewElt,
tocElt: sectionTocElt,
html: clonedElt.innerHTML,
heading,
html,
});
}
}
@ -454,74 +432,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
.start();
},
/**
* Apply the template to the file content
*/
// applyTemplate({ state, commit, dispatch, rootState }, template) {
// function groupToc(array, level = 1) {
// const result = [];
// let currentItem;
// function pushCurrentItem() {
// if (currentItem) {
// if (currentItem.children.length > 0) {
// currentItem.children = groupToc(currentItem.children, level + 1);
// }
// result.push(currentItem);
// }
// }
// array.forEach((item) => {
// if (item.level !== level) {
// currentItem = currentItem || {
// children: [],
// };
// currentItem.children.push(item);
// } else {
// pushCurrentItem();
// currentItem = item;
// }
// });
// pushCurrentItem();
// return result;
// }
// let toc = [];
// state.sectionDescList.cl_each((sectionDesc) => {
// if (sectionDesc.heading) {
// toc.push({
// title: sectionDesc.heading.title,
// level: sectionDesc.heading.level,
// anchor: sectionDesc.heading.anchor,
// children: [],
// });
// }
// });
// toc = groupToc(toc);
// const view = {
// file: {
// name: rootState.file.currentFile.name,
// content: {
// properties: rootState.file.currentFile.content.properties,
// text: rootState.file.currentFile.content.text,
// html: state.previewHtml,
// toc,
// },
// },
// };
// const worker = new window.Worker(clVersion.getAssetPath('templateWorker-min.js'));
// worker.postMessage([template, view, clSettingSvc.values.handlebarsHelpers]);
// return new Promise((resolve, reject) => {
// worker.addEventListener('message', (e) => {
// resolve(e.data.toString());
// });
// setTimeout(() => {
// worker.terminate();
// reject('Template generation timeout.');
// }, 10000);
// });
// },
/**
* Pass the elements to the store and initialize the editor.
*/
@ -531,7 +441,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.tocElt = tocElt;
editorEngineSvc.createClEditor(editorElt);
editorEngineSvc.clEditor.toggleEditable(false);
editorEngineSvc.clEditor.on('contentChanged', (content, diffs, sectionList) => {
const parsingCtx = {
...this.parsingCtx,
@ -543,18 +452,20 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
input: Object.create(editorEngineSvc.clEditor),
});
this.pagedownEditor.run();
// state.pagedownEditor.hooks.set('insertLinkDialog', (callback) => {
// clEditorSvc.linkDialogCallback = callback
// clEditorLayoutSvc.currentControl = 'linkDialog'
// scope.$evalAsync()
// return true
// })
// state.pagedownEditor.hooks.set('insertImageDialog', (callback) => {
// clEditorSvc.imageDialogCallback = callback
// clEditorLayoutSvc.currentControl = 'imageDialog'
// scope.$evalAsync()
// return true
// })
this.pagedownEditor.hooks.set('insertLinkDialog', (callback) => {
store.dispatch('modal/open', {
type: 'link',
callback,
});
return true;
});
this.pagedownEditor.hooks.set('insertImageDialog', (callback) => {
store.dispatch('modal/open', {
type: 'image',
callback,
});
return true;
});
this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));
@ -636,7 +547,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
}, 100);
let imgEltsToCache = [];
if (store.state.editor.inlineImages) {
if (store.getters['data/computedSettings'].editor.inlineImages) {
editorEngineSvc.clEditor.highlighter.on('sectionHighlighted', (section) => {
section.elt.getElementsByClassName('token img').cl_each((imgTokenElt) => {
const srcElt = imgTokenElt.querySelector('.token.cl-src');
@ -683,10 +594,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.$emit('inited');
// scope.$watch('editorLayoutSvc.isEditorOpen', function (isOpen) {
// clEditorSvc.cledit.toggleEditable(isOpen)
// })
// scope.$watch('editorLayoutSvc.currentControl', function (currentControl) {
// !currentControl && setTimeout(function () {
// !scope.isDialogOpen && clEditorSvc.cledit && clEditorSvc.cledit.focus()
@ -705,6 +612,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// })
// Watch file content changes
let lastContentId = null;
let lastProperties;
store.watch(
() => store.getters['content/current'].hash,
() => {
@ -717,12 +626,15 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
initClEditor = true;
}
// Track properties changes
const options = extensionSvc.getOptions(content.properties, true);
if (JSON.stringify(options) !== JSON.stringify(editorSvc.options)) {
editorSvc.options = options;
editorSvc.initPrism();
editorSvc.initConverter();
initClEditor = true;
if (content.properties !== lastProperties) {
lastProperties = content.properties;
const options = extensionSvc.getOptions(store.getters['content/currentProperties']);
if (JSON.stringify(options) !== JSON.stringify(editorSvc.options)) {
editorSvc.options = options;
editorSvc.initPrism();
editorSvc.initConverter();
initClEditor = true;
}
}
if (initClEditor) {
editorSvc.initClEditor();
@ -733,6 +645,13 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
immediate: true,
});
// Disable editor if hidden or if no content is loaded
store.watch(
() => store.getters['content/current'].id && store.getters['layout/styles'].showEditor,
editable => editorEngineSvc.clEditor.toggleEditable(!!editable), {
immediate: true,
});
store.watch(() => store.getters['layout/styles'],
() => editorSvc.measureSectionDimensions(false, true));
},

121
src/services/exportSvc.js Normal file
View File

@ -0,0 +1,121 @@
import FileSaver from 'file-saver';
import TemplateWorker from 'worker-loader!./templateWorker.js'; // eslint-disable-line
import localDbSvc from './localDbSvc';
import markdownConversionSvc from './markdownConversionSvc';
import extensionSvc from './extensionSvc';
import utils from './utils';
import store from '../store';
import htmlSanitizer from '../libs/htmlSanitizer';
function groupHeadings(headings, level = 1) {
const result = [];
let currentItem;
function pushCurrentItem() {
if (currentItem) {
if (currentItem.children.length > 0) {
currentItem.children = groupHeadings(currentItem.children, level + 1);
}
result.push(currentItem);
}
}
headings.forEach((heading) => {
if (heading.level !== level) {
currentItem = currentItem || {
children: [],
};
currentItem.children.push(heading);
} else {
pushCurrentItem();
currentItem = heading;
}
});
pushCurrentItem();
return result;
}
export default {
/**
* Apply the template to the file content
*/
applyTemplate(fileId, template = {
value: '{{{files.0.content.text}}}',
helpers: '',
}) {
const file = store.state.file.itemMap[fileId];
return localDbSvc.loadItem(`${fileId}/content`)
.then((content) => {
const properties = utils.computeProperties(content.properties);
const options = extensionSvc.getOptions(properties);
const converter = markdownConversionSvc.createConverter(options, true);
const parsingCtx = markdownConversionSvc.parseSections(converter, content.text);
const conversionCtx = markdownConversionSvc.convert(parsingCtx);
const html = conversionCtx.htmlSectionList.map(htmlSanitizer.sanitizeHtml).join('');
const elt = document.createElement('div');
elt.innerHTML = html;
// Unwrap tables
elt.querySelectorAll('.table-wrapper').cl_each((wrapperElt) => {
while (wrapperElt.firstChild) {
wrapperElt.parentNode.appendChild(wrapperElt.firstChild);
}
wrapperElt.parentNode.removeChild(wrapperElt);
});
// Make TOC
const headings = elt.querySelectorAll('h1,h2,h3,h4,h5,h6').cl_map(headingElt => ({
title: headingElt.textContent,
anchor: headingElt.id,
level: parseInt(headingElt.tagName.slice(1), 10),
children: [],
}));
const toc = groupHeadings(headings);
const view = {
files: [{
name: file.name,
content: {
text: content.text,
properties,
yamlProperties: content.properties,
html: elt.innerHTML,
toc,
},
}],
};
// Run template conversion in a Worker to prevent attacks from helpers
const worker = new TemplateWorker();
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
worker.terminate();
reject('Template generation timeout.');
}, 10000);
worker.addEventListener('message', (e) => {
clearTimeout(timeoutId);
worker.terminate();
// e.data can contain unsafe data if helpers attempts to call postMessage
const [err, result] = e.data;
if (err) {
reject(err.toString());
} else {
resolve(result.toString());
}
});
worker.postMessage([template.value, view, template.helpers]);
});
});
},
/**
* Export a file to disk.
*/
exportToDisk(fileId, type, template) {
const file = store.state.file.itemMap[fileId];
return this.applyTemplate(fileId, template)
.then((res) => {
const blob = new Blob([res], {
type: 'text/plain;charset=utf-8',
});
FileSaver.saveAs(blob, `${file.name}.${type}`);
});
},
};

View File

@ -27,10 +27,10 @@ export default {
}, {});
},
initConverter(markdown, options, isCurrentFile) {
initConverter(markdown, options) {
// Use forEach as it's a sparsed array
initConverterListeners.forEach((listener) => {
listener(markdown, options, isCurrentFile);
listener(markdown, options);
});
},

View File

@ -255,7 +255,7 @@ export default {
// Put item in the store
dbItem.tx = undefined;
store.commit(`${dbItem.type}/setItem`, dbItem);
resolve();
resolve(dbItem);
}
};
}, () => onError());
@ -266,16 +266,34 @@ export default {
* Unload from the store contents that haven't been opened recently
*/
unloadContents() {
// Keep only last opened files in memory
const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']);
Object.keys(contentTypes).forEach((type) => {
store.getters[`${type}/items`].forEach((item) => {
const [fileId] = item.id.split('/');
if (!lastOpenedFileIds.has(fileId)) {
// Remove item from the store
store.commit(`${type}/deleteItem`, item.id);
}
return this.sync()
.then(() => {
// Keep only last opened files in memory
const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']);
Object.keys(contentTypes).forEach((type) => {
store.getters[`${type}/items`].forEach((item) => {
const [fileId] = item.id.split('/');
if (!lastOpenedFileIds.has(fileId)) {
// Remove item from the store
store.commit(`${type}/deleteItem`, item.id);
}
});
});
});
});
},
/**
* Drop the database
*/
removeDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('stackedit-db');
request.onerror = reject;
request.onsuccess = resolve;
})
.then(() => {
localStorage.removeItem('localDbVersion');
window.location.reload();
}, () => console.error('Could not delete local database.'));
},
};

View File

@ -129,13 +129,13 @@ const markdownConversionSvc = {
* Creates a converter and init it with extensions.
* @returns {Object} A converter.
*/
createConverter(options, isCurrentFile) {
createConverter(options) {
// Let the listeners add the rules
const converter = new MarkdownIt('zero');
converter.core.ruler.enable([], true);
converter.block.ruler.enable([], true);
converter.inline.ruler.enable([], true);
extensionSvc.initConverter(converter, options, isCurrentFile);
extensionSvc.initConverter(converter, options);
Object.keys(startSectionBlockTypeMap).forEach((type) => {
const rule = converter.renderer.rules[type] || converter.renderer.renderToken;
converter.renderer.rules[type] = (tokens, idx, opts, env, self) => {

View File

@ -243,13 +243,6 @@ export default {
rest: latex,
},
};
rest['latex block'] = {
pattern: /\\begin\{([a-z]*\*?)\}[\s\S]*?\\?\\end\{\1\}/g,
inside: {
keyword: /\\(begin|end)/,
rest: latex,
},
};
}
if (options.footnote) {
rest.inlinefn = {

View File

@ -1 +1,3 @@
import './shortcuts';
import './keystrokes';
import './scrollSync';

View File

@ -0,0 +1,174 @@
import cledit from '../../libs/cledit';
import editorSvc from '../editorSvc';
import editorEngineSvc from '../editorEngineSvc';
const Keystroke = cledit.Keystroke;
const indentRegexp = /^ {0,3}>[ ]*|^[ \t]*[*+-][ \t]|^([ \t]*)\d+\.[ \t]|^\s+/;
let clearNewline;
let lastSelection;
function fixNumberedList(state, indent) {
if (state.selection || indent === undefined) {
return;
}
const spaceIndent = indent.replace(/\t/g, ' ');
const indentRegex = new RegExp(`^[ \\s]*$|^${spaceIndent}(\\d+\\.[ \\t])?(( )?.*)$`);
function getHits(lines) {
let hits = [];
let pendingHits = [];
function flush() {
if (!pendingHits.hasHit && pendingHits.hasNoIndent) {
return false;
}
hits = hits.concat(pendingHits);
pendingHits = [];
return true;
}
lines.some((line) => {
const match = line.replace(
/^[ \t]*/, wholeMatch => wholeMatch.replace(/\t/g, ' ')).match(indentRegex);
if (!match || line.match(/^#+ /)) { // Line not empty, not indented, or title
flush();
return true;
}
pendingHits.push({
line,
match,
});
if (match[2] !== undefined) {
if (match[1]) {
pendingHits.hasHit = true;
} else if (!match[3]) {
pendingHits.hasNoIndent = true;
}
} else if (!flush()) {
return true;
}
return false;
});
return hits;
}
function formatHits(hits) {
let num;
return hits.map((hit) => {
if (hit.match[1]) {
if (!num) {
num = parseInt(hit.match[1], 10);
}
const result = indent + num + hit.match[1].slice(-2) + hit.match[2];
num += 1;
return result;
}
return hit.line;
});
}
const before = state.before.split('\n');
before.unshift(''); // Add an extra line (fixes #184)
const after = state.after.split('\n');
let currentLine = before.pop() || '';
const currentPos = currentLine.length;
currentLine += after.shift() || '';
let lines = before.concat(currentLine).concat(after);
let idx = before.length - getHits(before.slice().reverse()).length; // Prevents starting from 0
while (idx <= before.length + 1) {
const hits = formatHits(getHits(lines.slice(idx)));
if (!hits.length) {
idx += 1;
} else {
lines = lines.slice(0, idx).concat(hits).concat(lines.slice(idx + hits.length));
idx += hits.length;
}
}
currentLine = lines[before.length];
state.before = lines.slice(1, before.length); // As we've added an extra line
state.before.push(currentLine.slice(0, currentPos));
state.before = state.before.join('\n');
state.after = [currentLine.slice(currentPos)].concat(lines.slice(before.length + 1));
state.after = state.after.join('\n');
}
function enterKeyHandler(evt, state) {
if (evt.which !== 13) {
// Not enter
clearNewline = false;
return false;
}
evt.preventDefault();
const lf = state.before.lastIndexOf('\n') + 1;
const previousLine = state.before.slice(lf);
const indentMatch = previousLine.match(indentRegexp) || [''];
if (clearNewline && !state.selection && state.before.length === lastSelection) {
state.before = state.before.substring(0, lf);
state.selection = '';
clearNewline = false;
fixNumberedList(state, indentMatch[1]);
return true;
}
clearNewline = false;
const indent = indentMatch[0];
if (indent.length) {
clearNewline = true;
}
editorEngineSvc.clEditor.undoMgr.setCurrentMode('single');
state.before += `\n${indent}`;
state.selection = '';
lastSelection = state.before.length;
fixNumberedList(state, indentMatch[1]);
return true;
}
function tabKeyHandler(evt, state) {
if (evt.which !== 9 || evt.metaKey || evt.ctrlKey) {
// Not tab
return false;
}
const strSplice = (str, i, remove, add) =>
str.slice(0, i) + (add || '') + str.slice(i + (+remove || 0));
evt.preventDefault();
const isInverse = evt.shiftKey;
const lf = state.before.lastIndexOf('\n') + 1;
const previousLine = state.before.slice(lf) + state.selection + state.after;
const indentMatch = previousLine.match(indentRegexp);
if (isInverse) {
const previousChar = state.before.slice(-1);
if (/\s/.test(state.before.charAt(lf))) {
state.before = strSplice(state.before, lf, 1);
if (indentMatch) {
fixNumberedList(state, indentMatch[1]);
if (indentMatch[1]) {
fixNumberedList(state, indentMatch[1].slice(1));
}
}
}
const selection = previousChar + state.selection;
state.selection = selection.replace(/\n[ \t]/gm, '\n');
if (previousChar) {
state.selection = state.selection.slice(1);
}
} else if (state.selection || indentMatch) {
state.before = strSplice(state.before, lf, 0, '\t');
state.selection = state.selection.replace(/\n(?=.)/g, '\n\t');
if (indentMatch) {
fixNumberedList(state, indentMatch[1]);
fixNumberedList(state, `\t${indentMatch[1]}`);
}
} else {
state.before += '\t';
}
return true;
}
editorSvc.$on('inited', () => {
editorEngineSvc.clEditor.addKeystroke(new Keystroke(enterKeyHandler, 50));
editorEngineSvc.clEditor.addKeystroke(new Keystroke(tabKeyHandler, 50));
});

View File

@ -35,7 +35,7 @@ function throttle(func, wait) {
const doScrollSync = () => {
const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview;
skipAnimation = false;
if (!store.state.editor.scrollSync || !sectionDescList || sectionDescList.length === 0) {
if (!store.getters['data/computedSettings'].scrollSync || !sectionDescList || sectionDescList.length === 0) {
return;
}
let editorScrollTop = editorScrollerElt.scrollTop;
@ -117,7 +117,7 @@ const forceScrollSync = () => {
doScrollSync();
}
};
store.watch(state => state.editor.scrollSync, forceScrollSync);
store.watch(() => store.getters['data/computedSettings'].scrollSync, forceScrollSync);
editorSvc.$on('inited', () => {
editorScrollerElt = editorSvc.editorElt.parentNode;

View File

@ -0,0 +1,48 @@
import Mousetrap from 'mousetrap';
import store from '../../store';
import editorSvc from '../../services/editorSvc';
import syncSvc from '../../services/syncSvc';
// Skip shortcuts if modal is open or editor is hidden
Mousetrap.prototype.stopCallback = () => store.state.modal.config || !store.getters['layout/styles'].showEditor;
const pagedownHandler = name => () => editorSvc.pagedownEditor.uiManager.doClick(name);
const methods = {
bold: pagedownHandler('bold'),
italic: pagedownHandler('italic'),
link: pagedownHandler('link'),
quote: pagedownHandler('quote'),
code: pagedownHandler('code'),
image: pagedownHandler('image'),
olist: pagedownHandler('olist'),
ulist: pagedownHandler('ulist'),
heading: pagedownHandler('heading'),
hr: pagedownHandler('hr'),
sync: () => syncSvc.isSyncPossible() && syncSvc.requestSync(),
};
store.watch(
() => store.getters['data/computedSettings'],
(computedSettings) => {
Mousetrap.reset();
const shortcuts = computedSettings.shortcuts;
shortcuts.forEach((shortcut) => {
if (shortcut.keys) {
const method = shortcut.method || shortcut;
let params = shortcut.params || [];
if (!Array.isArray(params)) {
params = [params];
}
if (Object.prototype.hasOwnProperty.call(methods, method)) {
Mousetrap.bind(shortcut.keys.toString(), () => {
methods[method].apply(null, params);
return false; // preventDefault
});
}
}
});
}, {
immediate: true,
});

View File

@ -1,30 +0,0 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils';
export default {
downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.gdriveFileId)
.then(content => providerUtils.parseContent(content));
},
uploadContent(token, item, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId];
const name = (file && file.name) || 'Untitled';
const parents = [];
if (syncLocation.gdriveParentId) {
parents.push(syncLocation.gdriveParentId);
}
return googleHelper.saveFile(
token,
name,
parents,
providerUtils.serializeContent(item),
syncLocation && syncLocation.gdriveId,
ifNotTooLate,
)
.then(gdriveFile => ({
...syncLocation,
gdriveId: gdriveFile.id,
}));
},
};

View File

@ -37,14 +37,13 @@ export default {
}
},
saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.saveFile(
token,
JSON.stringify(item),
['appDataFolder'],
null,
syncData && syncData.id,
ifNotTooLate,
)
return googleHelper.saveAppDataFile(
token,
JSON.stringify(item), ['appDataFolder'],
null,
syncData && syncData.id,
ifNotTooLate,
)
.then(file => ({
// Build sync data
id: file.id,
@ -83,17 +82,16 @@ export default {
return Promise.resolve();
}
return googleHelper.saveAppDataFile(
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
['appDataFolder'],
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}), ['appDataFolder'],
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
.then(file => store.dispatch('data/setSyncData', {
...store.getters['data/syncData'],
[file.id]: {

View File

@ -0,0 +1,85 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils';
import utils from '../utils';
const defaultFilename = 'Untitled';
export default {
downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content));
},
uploadContent(token, item, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId];
const name = (file && file.name) || defaultFilename;
const parents = [];
if (syncLocation.driveParentId) {
parents.push(syncLocation.driveParentId);
}
return googleHelper.saveFile(
token,
name,
parents,
providerUtils.serializeContent(item),
syncLocation && syncLocation.driveFileId,
ifNotTooLate,
)
.then(driveFile => ({
...syncLocation,
driveFileId: driveFile.id,
}));
},
openFiles(token, files) {
const openOneFile = () => {
const file = files.pop();
if (!file) {
return null;
}
let syncLocation;
// Try to find an existing sync location
store.getters['syncLocation/items'].some((existingSyncLocation) => {
if (existingSyncLocation.driveFileId === file.id) {
syncLocation = existingSyncLocation;
}
return syncLocation;
});
if (syncLocation) {
// Sync location already exists, just open the file
this.$store.commit('file/setCurrentId', syncLocation.fileId);
return openOneFile();
}
// Sync location does not exist, download content from Google Drive and create the file
syncLocation = {
driveFileId: file.id,
provider: 'googleDrive',
sub: token.sub,
};
return this.downloadContent(token, syncLocation)
.then((content) => {
const id = utils.uid();
delete content.history;
store.commit('content/setItem', {
...content,
id: `${id}/content`,
});
store.commit('file/setItem', {
id,
name: (file.name || defaultFilename).slice(0, 250),
parentId: store.getters['file/current'].parentId,
});
store.commit('syncLocation/setItem', {
...syncLocation,
id: utils.uid(),
fileId: id,
});
store.commit('file/setCurrentId', id);
}, () => {
console.error(`Could not open file ${file.id}.`);
})
.then(() => openOneFile());
};
return Promise.resolve()
.then(() => openOneFile());
},
};

View File

@ -4,29 +4,18 @@ import store from '../../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h)
let gapi;
let google;
// const scopeMap = {
// profile: [
// 'https://www.googleapis.com/auth/userinfo.profile',
// ],
// gdrive: [
// 'https://www.googleapis.com/auth/drive.install',
// store.getters['data/settings'].gdriveFullAccess === true ?
// 'https://www.googleapis.com/auth/drive' :
// 'https://www.googleapis.com/auth/drive.file',
// ],
// blogger: [
// 'https://www.googleapis.com/auth/blogger',
// ],
// picasa: [
// 'https://www.googleapis.com/auth/photos',
// ],
// };
const gdriveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata'];
const getGdriveScopes = () => [store.getters['data/settings'].gdriveFullAccess === true
const driveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata'];
const getDriveScopes = token => [token.driveFullAccess
? 'https://www.googleapis.com/auth/drive'
: 'https://www.googleapis.com/auth/drive.file'];
: 'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.install'];
// const bloggerScopes = ['https://www.googleapis.com/auth/blogger'];
const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker'];
const request = (token, options) => utils.request({
...options,
@ -101,7 +90,7 @@ export default {
'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId,
response_type: 'token',
scope: scopes.join(' '),
scope: ['openid', ...scopes].join(' '), // Need openid for user info
hd: appsDomain,
login_hint: sub,
prompt: silent ? 'none' : null,
@ -128,7 +117,12 @@ export default {
accessToken: data.accessToken,
expiresOn: Date.now() + (data.expiresIn * 1000),
sub: res.body.sub,
isLogin: !store.getters['data/loginToken'],
isLogin: !store.getters['data/loginToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1,
isPhotos: scopes.indexOf('https://www.googleapis.com/auth/photos') !== -1,
driveFullAccess: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1,
};
}))
// Call the tokeninfo endpoint
@ -140,11 +134,14 @@ export default {
token.name = res.body.displayName;
const existingToken = store.getters['data/googleTokens'][token.sub];
if (existingToken) {
if (!sub) {
throw new Error('Google account already linked.');
}
// Add isLogin and nextPageToken to token
token.isLogin = existingToken.isLogin;
// We probably retrieved a new token with restricted scopes.
// That's no problem, token will be refreshed later with merged scopes.
// Save flags
token.isLogin = existingToken.isLogin || token.isLogin;
token.isDrive = existingToken.isDrive || token.isDrive;
token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
// Save nextPageToken
token.nextPageToken = existingToken.nextPageToken;
}
// Add token to googleTokens
@ -162,28 +159,49 @@ export default {
return Promise.resolve()
.then(() => {
if (mergedScopes.length === lastToken.scopes.length) {
if (mergedScopes.length === lastToken.scopes.length &&
lastToken.expiresOn > Date.now() + tokenExpirationMargin
) {
return lastToken;
}
// New scopes are requested, popup an authorize window
return this.startOauth2(mergedScopes, sub);
})
.then((refreshedToken) => {
if (refreshedToken.expiresOn > Date.now() + tokenExpirationMargin) {
// Token is fresh enough
return refreshedToken;
}
// Token is almost outdated, try to take one in background
// New scopes are requested or existing token is going to expire.
// Try to get a new token in background
return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window
.catch(() => this.startOauth2(mergedScopes, sub));
});
},
loadClientScript() {
if (gapi) {
return Promise.resolve();
}
return utils.loadScript('https://apis.google.com/js/api.js')
.then(() => Promise.all(libraries.map(
library => new Promise((resolve, reject) => window.gapi.load(library, {
callback: resolve,
onerror: reject,
timeout: 30000,
ontimeout: reject,
})))))
.then(() => {
gapi = window.gapi;
google = window.google;
});
},
signin() {
return this.startOauth2(driveAppDataScopes);
},
addDriveAccount(fullAccess = false) {
return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }));
},
addPhotosAccount() {
return this.startOauth2(photosScopes);
},
getChanges(token) {
const result = {
changes: [],
};
return this.refreshToken(gdriveAppDataScopes, token)
return this.refreshToken(driveAppDataScopes, token)
.then((refreshedToken) => {
const getPage = (pageToken = '1') => request(refreshedToken, {
method: 'GET',
@ -207,27 +225,86 @@ export default {
});
},
saveFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(getGdriveScopes(), token)
return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
},
saveAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(gdriveAppDataScopes, token)
return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
},
downloadFile(token, id) {
return this.refreshToken(getGdriveScopes(), token)
return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => downloadFile(refreshedToken, id));
},
downloadAppDataFile(token, id) {
return this.refreshToken(gdriveAppDataScopes, token)
return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => downloadFile(refreshedToken, id));
},
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(gdriveAppDataScopes, token)
return this.refreshToken(driveAppDataScopes, token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
},
openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
return this.loadClientScript()
.then(() => this.refreshToken(scopes, token))
.then(refreshedToken => new Promise((resolve) => {
let picker;
const pickerBuilder = new google.picker.PickerBuilder()
.setOAuthToken(refreshedToken.accessToken)
.setCallback((data) => {
switch (data[google.picker.Response.ACTION]) {
case google.picker.Action.PICKED:
case google.picker.Action.CANCEL:
resolve(data.docs || []);
picker.dispose();
break;
default:
}
});
switch (type) {
default:
case 'doc': {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS);
view.setParent('root');
view.setIncludeFolders(true);
view.setMimeTypes([
'text/plain',
'text/x-markdown',
'application/octet-stream',
].join(','));
pickerBuilder.enableFeature(google.picker.Feature.NAV_HIDDEN);
pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED);
pickerBuilder.addView(view);
break;
}
case 'folder': {
const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
view.setParent('root');
view.setIncludeFolders(true);
view.setSelectFolderEnabled(true);
view.setMimeTypes('application/vnd.google-apps.folder');
pickerBuilder.enableFeature(google.picker.Feature.NAV_HIDDEN);
pickerBuilder.addView(view);
break;
}
case 'img': {
let view = new google.picker.PhotosView();
view.setType('flat');
pickerBuilder.addView(view);
view = new google.picker.PhotosView();
view.setType('ofuser');
pickerBuilder.addView(view);
pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD);
break;
}
}
picker = pickerBuilder.build();
picker.setVisible(true);
}));
},
};

View File

@ -26,13 +26,14 @@ export default {
}
if (Object.keys(data).length) {
const serializedData = b64Encode(JSON.stringify(data)).replace(/(.{50})/g, '$1\n');
result += `<!--stackedit_data:\n${serializedData}-->`;
result += `<!--stackedit_data:\n${serializedData}\n-->`;
}
return result;
},
parseContent(serializedContent) {
const result = emptyContent();
result.text = serializedContent;
result.history = [];
const extractedData = dataExtractor.exec(serializedContent);
if (extractedData) {
try {

View File

@ -4,7 +4,8 @@ import welcomeFile from '../data/welcomeFile.md';
import utils from './utils';
import diffUtils from './diffUtils';
import userActivitySvc from './userActivitySvc';
import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import googleDriveProvider from './providers/googleDriveProvider';
const lastSyncActivityKey = 'lastSyncActivity';
let lastSyncActivity;
@ -38,17 +39,21 @@ function setLastSyncActivity() {
function getSyncProvider(syncLocation) {
switch (syncLocation.provider) {
case 'gdriveAppData':
case 'googleDriveAppData':
default:
return gdriveAppDataProvider;
return googleDriveAppDataProvider;
case 'googleDrive':
return googleDriveProvider;
}
}
function getSyncToken(syncLocation) {
switch (syncLocation.provider) {
case 'gdriveAppData':
case 'googleDriveAppData':
default:
return store.getters['data/loginToken'];
case 'googleDrive':
return store.getters['data/googleTokens'][syncLocation.sub];
}
}
@ -135,7 +140,7 @@ function syncFile(fileId) {
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
];
if (isDataSyncPossible()) {
syncLocations.push({ id: 'main', provider: 'gdriveAppData', fileId });
syncLocations.unshift({ id: 'main', provider: 'googleDriveAppData', fileId });
}
let result;
syncLocations.some((syncLocation) => {
@ -200,7 +205,8 @@ function syncFile(fileId) {
}
}
// Store server content if any, and merged content which will be sent if different
// Store last sent if it's in the server history,
// and merged content which will be sent if different
const newSyncedContent = utils.deepCopy(syncedContent);
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
@ -248,10 +254,12 @@ function syncFile(fileId) {
return syncOneContentLocation();
})
.then(() => localDbSvc.unloadContents(), (err) => {
localDbSvc.unloadContents();
throw err;
})
.then(
() => localDbSvc.unloadContents(),
err => localDbSvc.unloadContents()
.then(() => {
throw err;
}))
.catch((err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
@ -263,11 +271,11 @@ function syncFile(fileId) {
function sync() {
const googleToken = store.getters['data/loginToken'];
return gdriveAppDataProvider.getChanges(googleToken)
return googleDriveAppDataProvider.getChanges(googleToken)
.then((changes) => {
// Apply changes
applyChanges(changes);
gdriveAppDataProvider.setAppliedChanges(googleToken, changes);
googleDriveAppDataProvider.setAppliedChanges(googleToken, changes);
// Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now();
@ -292,7 +300,7 @@ function sync() {
const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.hash !== item.hash) {
result = gdriveAppDataProvider.saveItem(
result = googleDriveAppDataProvider.saveItem(
googleToken,
// Use deepCopy to freeze objects
utils.deepCopy(item),
@ -327,7 +335,8 @@ function sync() {
) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
result = gdriveAppDataProvider.removeItem(googleToken, syncDataToRemove, ifNotTooLate)
result = googleDriveAppDataProvider
.removeItem(googleToken, syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id];
@ -396,7 +405,7 @@ function requestSync() {
clearInterval(intervalId);
if (!isSyncPossible()) {
// Cancel sync
reject();
reject('Sync not possible.');
return;
}

View File

@ -0,0 +1,98 @@
// This WebWorker provides a safe environment to run user scripts
// See http://stackoverflow.com/questions/10653809/making-webworkers-a-safe-environment/10796616
import Handlebars from 'handlebars';
// Classeur own helpers
Handlebars.registerHelper('tocToHtml', (toc, depth = 6) => {
function arrayToHtml(arr) {
if (!arr || !arr.length || arr[0].level > depth) {
return '';
}
const ulHtml = arr.map((item) => {
let result = '<li>';
if (item.anchor && item.title) {
result += `<a href="#${item.anchor}">${item.title}</a>`;
}
result += arrayToHtml(item.children);
return `${result}</li>`;
}).join('\n');
return `\n<ul>\n${ulHtml}\n</ul>\n`;
}
return new Handlebars.SafeString(arrayToHtml(toc));
});
const whiteList = {
self: 1,
onmessage: 1,
postMessage: 1,
global: 1,
whiteList: 1,
eval: 1,
Array: 1,
Boolean: 1,
Date: 1,
Function: 1,
Number: 1,
Object: 1,
RegExp: 1,
String: 1,
Error: 1,
EvalError: 1,
RangeError: 1,
ReferenceError: 1,
SyntaxError: 1,
TypeError: 1,
URIError: 1,
decodeURI: 1,
decodeURIComponent: 1,
encodeURI: 1,
encodeURIComponent: 1,
isFinite: 1,
isNaN: 1,
parseFloat: 1,
parseInt: 1,
Infinity: 1,
JSON: 1,
Math: 1,
NaN: 1,
undefined: 1,
safeEval: 1,
close: 1,
Handlebars: 1,
};
let global = self;
while (global !== Object.prototype) {
Object.getOwnPropertyNames(global).forEach((prop) => { // eslint-disable-line no-loop-func
if (!Object.prototype.hasOwnProperty.call(whiteList, prop)) {
try {
Object.defineProperty(global, prop, {
get() {
throw new Error(`Security Exception: cannot access ${prop}`);
},
configurable: false,
});
} catch (e) {
// Ignore
}
}
});
global = Object.getPrototypeOf(global);
}
function safeEval(code) {
eval(`"use strict";\n${code}`); // eslint-disable-line no-eval
}
self.onmessage = (evt) => {
try {
const template = Handlebars.compile(evt.data[0]);
const context = evt.data[1];
safeEval(evt.data[2]);
self.postMessage([null, template(context)]);
} catch (e) {
self.postMessage([e.toString()]);
}
close();
};

View File

@ -1,8 +1,14 @@
import yaml from 'js-yaml';
import defaultProperties from '../data/defaultFileProperties.yml';
// For sortObject
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
// For uid()
const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length;
const array = new Uint32Array(20);
const array = new Uint32Array(16);
// For addQueryParams()
const urlParser = window.document.createElement('a');
@ -10,10 +16,8 @@ const urlParser = window.document.createElement('a');
// For loadScript()
const scriptLoadingPromises = Object.create(null);
// For startOauth2()
const origin = `${location.protocol}//${location.host}`;
export default {
origin: `${location.protocol}//${location.host}`,
types: ['contentState', 'syncedContent', 'content', 'file', 'folder', 'syncLocation', 'data'],
deepCopy(obj) {
return obj == null ? obj : JSON.parse(JSON.stringify(obj));
@ -30,6 +34,15 @@ export default {
}, {});
});
},
sortObject(obj, sortFunc = key => key) {
const result = {};
const compare = (key1, key2) => collator.compare(
sortFunc(key1, obj[key1]), sortFunc(key2, obj[key2]));
Object.keys(obj).sort(compare).forEach((key) => {
result[key] = obj[key];
});
return result;
},
uid() {
crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join('');
@ -44,6 +57,27 @@ export default {
}
return hash;
},
computeProperties(yamlProperties) {
const customProperties = yaml.safeLoad(yamlProperties);
const properties = yaml.safeLoad(defaultProperties);
const override = (obj, opt) => {
const objType = Object.prototype.toString.call(obj);
const optType = Object.prototype.toString.call(opt);
if (objType !== optType) {
return obj;
} else if (objType !== '[object Object]') {
return opt === undefined ? obj : opt;
}
Object.keys({
...obj,
...opt,
}).forEach((key) => {
obj[key] = override(obj[key], opt[key]);
});
return obj;
};
return override(properties, customProperties);
},
randomize(value) {
return Math.floor((1 + (Math.random() * 0.2)) * value);
},
@ -85,17 +119,15 @@ export default {
// Build the authorize URL
const state = this.uid();
params.state = state;
params.redirect_uri = `${origin}/oauth2/callback.html`;
params.redirect_uri = `${this.origin}/oauth2/callback.html`;
const authorizeUrl = this.addQueryParams(url, params);
if (silent) {
// Use an iframe as wnd for silent mode
oauth2Context.iframeElt = document.createElement('iframe');
oauth2Context.iframeElt.style.position = 'absolute';
oauth2Context.iframeElt.style.left = '-9999px';
oauth2Context.iframeElt.onload = () => {
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 5 * 1000);
};
oauth2Context.iframeElt.onerror = () => oauth2Context.clean();
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean('Unknown error.'), 5 * 1000);
oauth2Context.iframeElt.onerror = () => oauth2Context.clean('Unknown error.');
oauth2Context.iframeElt.src = authorizeUrl;
document.body.appendChild(oauth2Context.iframeElt);
oauth2Context.wnd = oauth2Context.iframeElt.contentWindow;
@ -106,7 +138,7 @@ export default {
if (!oauth2Context.wnd) {
return Promise.reject();
}
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 120 * 1000);
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean('Timeout.'), 120 * 1000);
}
return new Promise((resolve, reject) => {
oauth2Context.clean = (errorMsg) => {
@ -129,7 +161,7 @@ export default {
oauth2Context.msgHandler = (event) => {
if (event.source === oauth2Context.wnd &&
event.origin === origin &&
event.origin === this.origin &&
event.data &&
event.data.state === state
) {
@ -145,9 +177,9 @@ export default {
oauth2Context.checkClosedInterval = !silent && setInterval(() => {
if (oauth2Context.wnd.closed) {
oauth2Context.clean();
oauth2Context.clean('Authorize window was closed');
}
}, 200);
}, 250);
});
},
request(configParam) {

View File

@ -10,7 +10,6 @@ import folder from './modules/folder';
import syncLocation from './modules/syncLocation';
import data from './modules/data';
import layout from './modules/layout';
import editor from './modules/editor';
import explorer from './modules/explorer';
import modal from './modules/modal';
import queue from './modules/queue';
@ -48,7 +47,6 @@ const store = new Vuex.Store({
syncLocation,
data,
layout,
editor,
explorer,
modal,
queue,

View File

@ -1,5 +1,6 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptyContent';
import utils from '../../services/utils';
const module = moduleTemplate(empty);
@ -7,6 +8,7 @@ module.getters = {
...module.getters,
current: (state, getters, rootState, rootGetters) =>
state.itemMap[`${rootGetters['file/current'].id}/content`] || empty(),
currentProperties: (state, getters) => utils.computeProperties(getters.current.properties),
};
module.actions = {

View File

@ -1,11 +1,17 @@
import yaml from 'js-yaml';
import moduleTemplate from './moduleTemplate';
import utils from '../../services/utils';
import defaultSettings from '../../data/defaultSettings.yml';
import defaultLocalSettings from '../../data/defaultLocalSettings';
import plainHtmlTemplate from '../../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../../data/styledHtmlTemplate.html';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
const empty = (id) => {
switch (id) {
case 'settings':
return itemTemplate(id, '\n');
case 'localSettings':
return itemTemplate(id, defaultLocalSettings());
default:
@ -37,12 +43,66 @@ module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
module.actions.toggleEditor = localSettingsToggler('showEditor');
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
module.actions.toggleSideBar = localSettingsToggler('showSideBar');
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.toggleFocusMode = localSettingsToggler('focusMode');
module.actions.setSideBarPanel = ({ dispatch }, value) => dispatch('patchLocalSettings', {
sideBarPanel: value === undefined ? 'menu' : value,
});
// Settings
module.getters.settings = getter('settings');
module.getters.computedSettings = (state, getters) => {
const customSettings = yaml.safeLoad(getters.settings);
const settings = yaml.safeLoad(defaultSettings);
const override = (obj, opt) => {
const objType = Object.prototype.toString.call(obj);
const optType = Object.prototype.toString.call(opt);
if (objType !== optType) {
return obj;
} else if (objType !== '[object Object]') {
return opt;
}
Object.keys(obj).forEach((key) => {
obj[key] = override(obj[key], opt[key]);
});
return obj;
};
return override(settings, customSettings);
};
module.actions.setSettings = setter('settings');
// Templates
module.getters.templates = getter('templates');
const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
name,
value,
helpers,
isAdditional: true,
});
const additionalTemplates = {
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
};
module.getters.allTemplates = (state, getters) => ({
...getters.templates,
...additionalTemplates,
});
module.actions.setTemplates = ({ commit }, data) => {
const dataToCommit = {
...data,
};
// We don't store additional templates
Object.keys(additionalTemplates).forEach((id) => {
delete dataToCommit[id];
});
commit('setItem', itemTemplate('templates', dataToCommit));
};
// Last opened
module.getters.lastOpened = getter('lastOpened');

View File

@ -1,16 +0,0 @@
const setter = propertyName => (state, value) => {
state[propertyName] = value;
};
export default {
namespaced: true,
state: {
// Configuration
inlineImages: true,
scrollSync: true,
},
mutations: {
setInlineImages: setter('inlineImages'),
setScrollSync: setter('scrollSync'),
},
};

View File

@ -1,4 +1,4 @@
const editorMinWidth = 280;
const editorMinWidth = 320;
const minPadding = 20;
const previewButtonWidth = 55;
const editorTopPadding = 10;
@ -15,7 +15,7 @@ const constants = {
statusBarHeight: 20,
};
function computeStyles(state, localSettings, styles = {
function computeStyles(state, computedSettings, localSettings, styles = {
showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar,
showStatusBar: localSettings.showStatusBar,
showEditor: localSettings.showEditor,
@ -49,7 +49,7 @@ function computeStyles(state, localSettings, styles = {
if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) {
styles.showSidePreview = false;
styles.showPreview = false;
return computeStyles(state, localSettings, styles);
return computeStyles(state, computedSettings, localSettings, styles);
}
styles.fontSize = 18;
@ -61,14 +61,14 @@ function computeStyles(state, localSettings, styles = {
if (doublePanelWidth < 1040) {
styles.textWidth = 830;
}
styles.textWidth *= state.editorWidthFactor;
styles.textWidth *= computedSettings.maxWidthFactor;
if (doublePanelWidth < styles.textWidth) {
styles.textWidth = doublePanelWidth;
}
if (styles.textWidth < 640) {
styles.fontSize -= 1;
}
styles.fontSize *= state.fontSizeFactor;
styles.fontSize *= computedSettings.fontSizeFactor;
const bottomPadding = Math.floor(styles.innerHeight / 2);
const panelWidth = Math.floor(doublePanelWidth / 2);
@ -98,21 +98,13 @@ function computeStyles(state, localSettings, styles = {
return styles;
}
const setter = propertyName => (state, value) => {
state[propertyName] = value;
};
export default {
namespaced: true,
state: {
editorWidthFactor: 1,
fontSizeFactor: 1,
bodyWidth: 0,
bodyHeight: 0,
},
mutations: {
setEditorWidthFactor: setter('editorWidthFactor'),
setFontSizeFactor: setter('fontSizeFactor'),
updateBodySize: (state) => {
state.bodyWidth = document.body.clientWidth;
state.bodyHeight = document.body.clientHeight;
@ -121,8 +113,9 @@ export default {
getters: {
constants: () => constants,
styles: (state, getters, rootState, rootGetters) => {
const computedSettings = rootGetters['data/computedSettings'];
const localSettings = rootGetters['data/localSettings'];
return computeStyles(state, localSettings);
return computeStyles(state, computedSettings, localSettings);
},
},
};

View File

@ -1,49 +1,54 @@
const confirmButtons = yesText => [{
text: 'No',
}, {
text: yesText || 'Yes',
resolve: true,
}];
export default {
namespaced: true,
state: {
content: null,
config: null,
},
mutations: {
setContent: (state, value) => {
state.content = value;
setConfig: (state, value) => {
state.config = value;
},
},
actions: {
open({ commit }, content) {
open({ commit }, param) {
return new Promise((resolve, reject) => {
if (!content.buttons) {
content.buttons = [{
text: 'OK',
resolve: true,
}];
}
content.buttons.forEach((button) => {
button.onClick = () => {
commit('setContent');
if (button.resolve) {
resolve(button.resolve);
} else {
reject();
}
let config = param;
if (typeof config === 'string') {
config = {
type: config,
};
});
commit('setContent', content);
}
config.resolve = (result) => {
if (config.onResolve) {
config.onResolve(result);
}
commit('setConfig');
resolve(result);
};
config.reject = (error) => {
commit('setConfig');
reject(error);
};
commit('setConfig', config);
});
},
notImplemented: ({ dispatch }) => dispatch('open', {
content: '<p>Sorry, this feature is not available yet...</p>',
rejectText: 'Ok',
}),
fileDeletion: ({ dispatch }, item) => dispatch('open', {
text: `<p>You are about to delete the file <b>${item.name}</b>. Are you sure ?</p>`,
buttons: confirmButtons('Yes, delete'),
content: `<p>You are about to delete the file <b>${item.name}</b>. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
folderDeletion: ({ dispatch }, item) => dispatch('open', {
text: `<p>You are about to delete the folder <b>${item.name}</b> and all its files. Are you sure ?</p>`,
buttons: confirmButtons('Yes, delete'),
content: `<p>You are about to delete the folder <b>${item.name}</b> and all its files. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean your local files and settings. Are you sure?</p>',
resolveText: 'Yes, clean',
rejectText: 'No',
}),
},
};

View File

@ -55,6 +55,15 @@ ajv@^4.11.2, ajv@^4.7.0, ajv@^4.9.1:
co "^4.6.0"
json-stable-stringify "^1.0.1"
ajv@^5.0.0:
version "5.2.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
dependencies:
co "^4.6.0"
fast-deep-equal "^1.0.0"
json-schema-traverse "^0.3.0"
json-stable-stringify "^1.0.1"
align-text@^0.1.1, align-text@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
@ -205,6 +214,10 @@ async-foreach@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542"
async@^1.4.0:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
async@^2.1.2, async@^2.1.5:
version "2.4.1"
resolved "https://registry.yarnpkg.com/async/-/async-2.4.1.tgz#62a56b279c98a11d0987096a01cc3eeb8eb7bbd7"
@ -1122,7 +1135,7 @@ cli-width@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
clipboard@^1.5.5:
clipboard@^1.5.5, clipboard@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
dependencies:
@ -1599,7 +1612,7 @@ debug@2.6.7:
dependencies:
ms "2.0.0"
debug@^2.1.1, debug@^2.2.0, debug@^2.6.0, debug@^2.6.8:
debug@^2.1.1, debug@^2.2.0, debug@^2.6.0:
version "2.6.8"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
dependencies:
@ -2084,6 +2097,10 @@ esprima@^3.1.1:
version "3.1.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
esprima@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804"
esquery@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.0.tgz#cfba8b57d7fba93f17298a8a006a04cda13d80fa"
@ -2235,6 +2252,10 @@ fancy-log@^1.1.0:
chalk "^1.1.1"
time-stamp "^1.0.0"
fast-deep-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
fast-levenshtein@~2.0.4:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
@ -2263,6 +2284,10 @@ file-loader@^0.11.1:
dependencies:
loader-utils "^1.0.2"
file-saver@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.3.tgz#cdd4c44d3aa264eac2f68ec165bc791c34af1232"
filename-regex@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
@ -2739,6 +2764,16 @@ gzip-size@^3.0.0:
dependencies:
duplexer "^0.1.1"
handlebars@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.10.tgz#3d30c718b09a3d96f23ea4cc1f403c4d3ba9ff4f"
dependencies:
async "^1.4.0"
optimist "^0.6.1"
source-map "^0.4.4"
optionalDependencies:
uglify-js "^2.6"
har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
@ -3289,6 +3324,13 @@ js-yaml@^3.4.3, js-yaml@^3.5.1:
argparse "^1.0.7"
esprima "^3.1.1"
js-yaml@^3.9.1:
version "3.9.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0"
dependencies:
argparse "^1.0.7"
esprima "^4.0.0"
js-yaml@~3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80"
@ -3312,6 +3354,10 @@ json-loader@^0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.4.tgz#8baa1365a632f58a3c46d20175fc6002c96e37de"
json-schema-traverse@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
json-schema@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
@ -3878,6 +3924,10 @@ minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
minimist@~0.0.1:
version "0.0.10"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
mixin-object@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e"
@ -3891,6 +3941,10 @@ mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd
dependencies:
minimist "0.0.8"
mousetrap@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.1.tgz#2a085f5c751294c75e7e81f6ec2545b29cbf42d9"
ms@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"
@ -4224,6 +4278,13 @@ opn@^4.0.2:
object-assign "^4.0.1"
pinkie-promise "^2.0.0"
optimist@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
dependencies:
minimist "~0.0.1"
wordwrap "~0.0.2"
optimize-css-assets-webpack-plugin@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-1.3.2.tgz#eb27456e21eefbd8080f31e8368c59684e585a2c"
@ -5259,6 +5320,12 @@ sax@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828"
schema-utils@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf"
dependencies:
ajv "^5.0.0"
scss-tokenizer@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
@ -5394,7 +5461,7 @@ source-map@0.5.x, source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, sourc
version "0.5.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
source-map@^0.4.2:
source-map@^0.4.2, source-map@^0.4.4:
version "0.4.4"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b"
dependencies:
@ -5870,6 +5937,15 @@ uglify-js@3.0.x:
commander "~2.9.0"
source-map "~0.5.1"
uglify-js@^2.6:
version "2.8.29"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
dependencies:
source-map "~0.5.1"
yargs "~3.10.0"
optionalDependencies:
uglify-to-browserify "~1.0.0"
uglify-js@^2.8.27:
version "2.8.27"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.27.tgz#47787f912b0f242e5b984343be8e35e95f694c9c"
@ -6222,10 +6298,21 @@ wordwrap@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
wordwrap@~0.0.2:
version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
wordwrap@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
worker-loader@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-0.8.1.tgz#e8e995331ea34df5bf68296824bfb7f0ad578d43"
dependencies:
loader-utils "^1.0.2"
schema-utils "^0.3.0"
wrap-ansi@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"