@@ -47,49 +127,98 @@
+
+
diff --git a/src/components/Toc.vue b/src/components/Toc.vue
index fc83ec82..8a1c4096 100644
--- a/src/components/Toc.vue
+++ b/src/components/Toc.vue
@@ -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 {
diff --git a/src/components/common/app.scss b/src/components/common/app.scss
index f25649fb..0fd6ddfe 100644
--- a/src/components/common/app.scss
+++ b/src/components/common/app.scss
@@ -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;
+ }
+}
diff --git a/src/components/common/variables.scss b/src/components/common/variables.scss
index 701c6d6e..eac0985c 100644
--- a/src/components/common/variables.scss
+++ b/src/components/common/variables.scss
@@ -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);
diff --git a/src/data/defaultFileProperties.yml b/src/data/defaultFileProperties.yml
new file mode 100644
index 00000000..c36ed837
--- /dev/null
+++ b/src/data/defaultFileProperties.yml
@@ -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
diff --git a/src/data/defaultLocalSettings.js b/src/data/defaultLocalSettings.js
index 91ffed59..9919af0b 100644
--- a/src/data/defaultLocalSettings.js
+++ b/src/data/defaultLocalSettings.js
@@ -6,4 +6,6 @@ export default () => ({
showSideBar: false,
showExplorer: false,
focusMode: false,
+ sideBarPanel: 'menu',
+ htmlExportLastTemplate: 'styledHtml',
});
diff --git a/src/data/defaultSettings.yml b/src/data/defaultSettings.yml
new file mode 100644
index 00000000..c24368f5
--- /dev/null
+++ b/src/data/defaultSettings.yml
@@ -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
+
diff --git a/src/data/emptySyncLocation.js b/src/data/emptySyncLocation.js
index 80c68268..2b9e182c 100644
--- a/src/data/emptySyncLocation.js
+++ b/src/data/emptySyncLocation.js
@@ -1,6 +1,7 @@
export default () => ({
id: null,
type: 'syncLocation',
+ provider: null,
fileId: null,
hash: 0,
});
diff --git a/src/data/emptyTemplateHelpers.js b/src/data/emptyTemplateHelpers.js
new file mode 100644
index 00000000..01d2eb25
--- /dev/null
+++ b/src/data/emptyTemplateHelpers.js
@@ -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(/
]*>/g, '')
+ );
+});
+*/
diff --git a/src/data/emptyTemplateValue.html b/src/data/emptyTemplateValue.html
new file mode 100644
index 00000000..04d0de88
--- /dev/null
+++ b/src/data/emptyTemplateValue.html
@@ -0,0 +1,24 @@
+
diff --git a/src/data/plainHtmlTemplate.html b/src/data/plainHtmlTemplate.html
new file mode 100644
index 00000000..42b6b5da
--- /dev/null
+++ b/src/data/plainHtmlTemplate.html
@@ -0,0 +1 @@
+{{{files.0.content.html}}}
diff --git a/src/data/styledHtmlTemplate.html b/src/data/styledHtmlTemplate.html
new file mode 100644
index 00000000..563af978
--- /dev/null
+++ b/src/data/styledHtmlTemplate.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ {{files.0.name}}
+
+
+
+
+
+ {{{files.0.content.html}}}
+
+
+
diff --git a/src/extensions/katexExt.js b/src/extensions/katexExt.js
index c2d943c5..29acf265 100644
--- a/src/extensions/katexExt.js
+++ b/src/extensions/katexExt.js
@@ -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) => {
diff --git a/src/extensions/markdownExt.js b/src/extensions/markdownExt.js
index 3f3be3a3..3af45c1b 100644
--- a/src/extensions/markdownExt.js
+++ b/src/extensions/markdownExt.js
@@ -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({
diff --git a/src/icons/CodeTags.vue b/src/icons/CodeTags.vue
new file mode 100644
index 00000000..2d085d5c
--- /dev/null
+++ b/src/icons/CodeTags.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/Download.vue b/src/icons/Download.vue
new file mode 100644
index 00000000..c9f80165
--- /dev/null
+++ b/src/icons/Download.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/FileMultiple.vue b/src/icons/FileMultiple.vue
new file mode 100644
index 00000000..a1dd4914
--- /dev/null
+++ b/src/icons/FileMultiple.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/GoogleDrive.vue b/src/icons/GoogleDrive.vue
new file mode 100644
index 00000000..1575b9f7
--- /dev/null
+++ b/src/icons/GoogleDrive.vue
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/icons/GooglePhotos.vue b/src/icons/GooglePhotos.vue
new file mode 100644
index 00000000..44127b22
--- /dev/null
+++ b/src/icons/GooglePhotos.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/src/icons/HardDisk.vue b/src/icons/HardDisk.vue
new file mode 100644
index 00000000..54971c73
--- /dev/null
+++ b/src/icons/HardDisk.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/Logout.vue b/src/icons/Logout.vue
new file mode 100644
index 00000000..3f0be0d6
--- /dev/null
+++ b/src/icons/Logout.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/Menu.vue b/src/icons/Menu.vue
deleted file mode 100644
index 44a53f13..00000000
--- a/src/icons/Menu.vue
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/src/icons/Stackedit.vue b/src/icons/Stackedit.vue
new file mode 100644
index 00000000..dc8b5300
--- /dev/null
+++ b/src/icons/Stackedit.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/src/icons/Upload.vue b/src/icons/Upload.vue
new file mode 100644
index 00000000..26ab37d0
--- /dev/null
+++ b/src/icons/Upload.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/ViewList.vue b/src/icons/ViewList.vue
new file mode 100644
index 00000000..ab665d9b
--- /dev/null
+++ b/src/icons/ViewList.vue
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/icons/index.js b/src/icons/index.js
index 2451120c..ad88ec0f 100644
--- a/src/icons/index.js
+++ b/src/icons/index.js
@@ -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);
diff --git a/src/libs/cleditSelectionMgr.js b/src/libs/cleditSelectionMgr.js
index a59e94d8..93dd1d9d 100644
--- a/src/libs/cleditSelectionMgr.js
+++ b/src/libs/cleditSelectionMgr.js
@@ -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) {
diff --git a/src/services/animationSvc.js b/src/services/animationSvc.js
index dfe628cb..44f9187d 100644
--- a/src/services/animationSvc.js
+++ b/src/services/animationSvc.js
@@ -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;
}
diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js
index ed96f349..a5666980 100644
--- a/src/services/editorSvc.js
+++ b/src/services/editorSvc.js
@@ -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));
},
diff --git a/src/services/exportSvc.js b/src/services/exportSvc.js
new file mode 100644
index 00000000..8d130401
--- /dev/null
+++ b/src/services/exportSvc.js
@@ -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}`);
+ });
+ },
+};
diff --git a/src/services/extensionSvc.js b/src/services/extensionSvc.js
index 5fad6d2b..54c4b683 100644
--- a/src/services/extensionSvc.js
+++ b/src/services/extensionSvc.js
@@ -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);
});
},
diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js
index a2732881..f10c426e 100644
--- a/src/services/localDbSvc.js
+++ b/src/services/localDbSvc.js
@@ -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.'));
},
};
diff --git a/src/services/markdownConversionSvc.js b/src/services/markdownConversionSvc.js
index 4bbf1eae..6a187ec9 100644
--- a/src/services/markdownConversionSvc.js
+++ b/src/services/markdownConversionSvc.js
@@ -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) => {
diff --git a/src/services/markdownGrammarSvc.js b/src/services/markdownGrammarSvc.js
index 9d89ddab..ae5c3b26 100644
--- a/src/services/markdownGrammarSvc.js
+++ b/src/services/markdownGrammarSvc.js
@@ -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 = {
diff --git a/src/services/optional/index.js b/src/services/optional/index.js
index e1e5094c..268d3f71 100644
--- a/src/services/optional/index.js
+++ b/src/services/optional/index.js
@@ -1 +1,3 @@
+import './shortcuts';
+import './keystrokes';
import './scrollSync';
diff --git a/src/services/optional/keystrokes.js b/src/services/optional/keystrokes.js
new file mode 100644
index 00000000..2fb45c6d
--- /dev/null
+++ b/src/services/optional/keystrokes.js
@@ -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));
+});
diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js
index 741f7e53..41083078 100644
--- a/src/services/optional/scrollSync.js
+++ b/src/services/optional/scrollSync.js
@@ -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;
diff --git a/src/services/optional/shortcuts.js b/src/services/optional/shortcuts.js
new file mode 100644
index 00000000..d8de54e3
--- /dev/null
+++ b/src/services/optional/shortcuts.js
@@ -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,
+ });
diff --git a/src/services/providers/gdriveProvider.js b/src/services/providers/gdriveProvider.js
deleted file mode 100644
index 6e67e22f..00000000
--- a/src/services/providers/gdriveProvider.js
+++ /dev/null
@@ -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,
- }));
- },
-};
diff --git a/src/services/providers/gdriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js
similarity index 86%
rename from src/services/providers/gdriveAppDataProvider.js
rename to src/services/providers/googleDriveAppDataProvider.js
index eb51561f..18851e9e 100644
--- a/src/services/providers/gdriveAppDataProvider.js
+++ b/src/services/providers/googleDriveAppDataProvider.js
@@ -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]: {
diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js
new file mode 100644
index 00000000..c7fccb3b
--- /dev/null
+++ b/src/services/providers/googleDriveProvider.js
@@ -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());
+ },
+};
diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js
index 3b7b4fa3..9251c154 100644
--- a/src/services/providers/helpers/googleHelper.js
+++ b/src/services/providers/helpers/googleHelper.js
@@ -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);
+ }));
+ },
};
diff --git a/src/services/providers/providerUtils.js b/src/services/providers/providerUtils.js
index 9f47ff81..839e876c 100644
--- a/src/services/providers/providerUtils.js
+++ b/src/services/providers/providerUtils.js
@@ -26,13 +26,14 @@ export default {
}
if (Object.keys(data).length) {
const serializedData = b64Encode(JSON.stringify(data)).replace(/(.{50})/g, '$1\n');
- result += ``;
+ result += ``;
}
return result;
},
parseContent(serializedContent) {
const result = emptyContent();
result.text = serializedContent;
+ result.history = [];
const extractedData = dataExtractor.exec(serializedContent);
if (extractedData) {
try {
diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js
index 25e090ed..de0dbf0e 100644
--- a/src/services/syncSvc.js
+++ b/src/services/syncSvc.js
@@ -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;
}
diff --git a/src/services/templateWorker.js b/src/services/templateWorker.js
new file mode 100644
index 00000000..eb662f1b
--- /dev/null
+++ b/src/services/templateWorker.js
@@ -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 = '
';
+ if (item.anchor && item.title) {
+ result += `${item.title}`;
+ }
+ result += arrayToHtml(item.children);
+ return `${result}`;
+ }).join('\n');
+ return `\n
\n`;
+ }
+ return new Handlebars.SafeString(arrayToHtml(toc));
+});
+
+const whiteList = {
+ self: 1,
+ onmessage: 1,
+ postMessage: 1,
+ global: 1,
+ whiteList: 1,
+ eval: 1,
+ Array: 1,
+ Boolean: 1,
+ Date: 1,
+ Function: 1,
+ Number: 1,
+ Object: 1,
+ RegExp: 1,
+ String: 1,
+ Error: 1,
+ EvalError: 1,
+ RangeError: 1,
+ ReferenceError: 1,
+ SyntaxError: 1,
+ TypeError: 1,
+ URIError: 1,
+ decodeURI: 1,
+ decodeURIComponent: 1,
+ encodeURI: 1,
+ encodeURIComponent: 1,
+ isFinite: 1,
+ isNaN: 1,
+ parseFloat: 1,
+ parseInt: 1,
+ Infinity: 1,
+ JSON: 1,
+ Math: 1,
+ NaN: 1,
+ undefined: 1,
+ safeEval: 1,
+ close: 1,
+ 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();
+};
diff --git a/src/services/utils.js b/src/services/utils.js
index ba241d49..35880e6e 100644
--- a/src/services/utils.js
+++ b/src/services/utils.js
@@ -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) {
diff --git a/src/store/index.js b/src/store/index.js
index d1af4c3e..b0ce215d 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -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,
diff --git a/src/store/modules/content.js b/src/store/modules/content.js
index dfe99851..67626011 100644
--- a/src/store/modules/content.js
+++ b/src/store/modules/content.js
@@ -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 = {
diff --git a/src/store/modules/data.js b/src/store/modules/data.js
index 99ca5f00..3c30a30b 100644
--- a/src/store/modules/data.js
+++ b/src/store/modules/data.js
@@ -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');
diff --git a/src/store/modules/editor.js b/src/store/modules/editor.js
deleted file mode 100644
index 43bfe24b..00000000
--- a/src/store/modules/editor.js
+++ /dev/null
@@ -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'),
- },
-};
diff --git a/src/store/modules/layout.js b/src/store/modules/layout.js
index f0970d45..c7b945c0 100644
--- a/src/store/modules/layout.js
+++ b/src/store/modules/layout.js
@@ -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);
},
},
};
diff --git a/src/store/modules/modal.js b/src/store/modules/modal.js
index 2351f891..477d7b7d 100644
--- a/src/store/modules/modal.js
+++ b/src/store/modules/modal.js
@@ -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: '
Sorry, this feature is not available yet...
',
+ rejectText: 'Ok',
+ }),
fileDeletion: ({ dispatch }, item) => dispatch('open', {
- text: `
You are about to delete the file ${item.name}. Are you sure ?
`,
- buttons: confirmButtons('Yes, delete'),
+ content: `
You are about to delete the file ${item.name}. Are you sure?
`,
+ resolveText: 'Yes, delete',
+ rejectText: 'No',
}),
folderDeletion: ({ dispatch }, item) => dispatch('open', {
- text: `
You are about to delete the folder ${item.name} and all its files. Are you sure ?
`,
- buttons: confirmButtons('Yes, delete'),
+ content: `
You are about to delete the folder ${item.name} and all its files. Are you sure?
`,
+ resolveText: 'Yes, delete',
+ rejectText: 'No',
+ }),
+ reset: ({ dispatch }) => dispatch('open', {
+ content: '
This will clean your local files and settings. Are you sure?
',
+ resolveText: 'Yes, clean',
+ rejectText: 'No',
}),
},
};
diff --git a/yarn.lock b/yarn.lock
index 58375dd0..594f7960 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"