New workspace and sync management modals. UI enhancements

This commit is contained in:
Benoit Schweblin 2018-07-04 00:41:24 +01:00
parent 7a87015af1
commit a1673d3e87
107 changed files with 1210 additions and 889 deletions

View File

@ -21,7 +21,7 @@ import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc';
import sponsorSvc from '../services/sponsorSvc';
import tempFileSvc from '../services/tempFileSvc';
import './common/globals';
import './common/vueGlobals';
const themeClasses = {
light: ['app--light'],
@ -53,7 +53,7 @@ export default {
this.ready = true;
tempFileSvc.setReady();
} catch (err) {
if (err && err.message !== 'reload') {
if (err && err.message !== 'RELOAD') {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);
}

View File

@ -4,7 +4,7 @@
<script>
import Prism from 'prismjs';
import cledit from '../services/cledit';
import cledit from '../services/editor/cledit';
export default {
props: ['value', 'lang', 'disabled'],

View File

@ -19,7 +19,7 @@
<script>
import { mapMutations, mapActions } from 'vuex';
import fileSvc from '../services/fileSvc';
import workspaceSvc from '../services/workspaceSvc';
import explorerSvc from '../services/explorerSvc';
export default {
@ -102,10 +102,10 @@ export default {
if (!cancel && !newChildNode.isNil && newChildNode.item.name) {
try {
if (newChildNode.isFolder) {
const item = await fileSvc.storeItem(newChildNode.item);
const item = await workspaceSvc.storeItem(newChildNode.item);
this.select(item.id);
} else {
const item = await fileSvc.createFile(newChildNode.item);
const item = await workspaceSvc.createFile(newChildNode.item);
this.select(item.id);
}
} catch (e) {
@ -120,7 +120,7 @@ export default {
this.setEditingId(null);
if (!cancel && item.id && value) {
try {
await fileSvc.storeItem({
await workspaceSvc.storeItem({
...item,
name: value,
});
@ -147,7 +147,7 @@ export default {
&& !targetNode.isNil
&& sourceNode.item.id !== targetNode.item.id
) {
fileSvc.storeItem({
workspaceSvc.storeItem({
...sourceNode.item,
parentId: targetNode.item.id,
});

View File

@ -34,7 +34,7 @@
<script>
import { mapState } from 'vuex';
import editorSvc from '../services/editorSvc';
import cledit from '../services/cledit';
import cledit from '../services/editor/cledit';
import store from '../store';
import EditorClassApplier from './common/EditorClassApplier';

View File

@ -5,7 +5,7 @@
<div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
<div class="modal__button-bar">
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
<button class="button" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
<button class="button button--resolve" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
</div>
</modal-inner>
</div>
@ -250,7 +250,7 @@ export default {
}
.modal__sub-title {
opacity: 0.5;
opacity: 0.6;
font-size: 0.75rem;
margin-bottom: 1.5rem;
}
@ -278,8 +278,10 @@ export default {
}
.modal__button-bar {
margin-top: 1.75rem;
text-align: right;
margin-top: 2rem;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.form-entry {

View File

@ -56,7 +56,7 @@ import tempFileSvc from '../services/tempFileSvc';
import utils from '../services/utils';
import pagedownButtons from '../data/pagedownButtons';
import store from '../store';
import fileSvc from '../services/fileSvc';
import workspaceSvc from '../services/workspaceSvc';
// According to mousetrap
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
@ -200,7 +200,7 @@ export default {
this.title = this.$store.getters['file/current'].name;
if (title) {
try {
await fileSvc.storeItem({
await workspaceSvc.storeItem({
...this.$store.getters['file/current'],
name: title,
});

View File

@ -13,7 +13,7 @@
</div>
<div class="side-bar__inner">
<main-menu v-if="panel === 'menu'"></main-menu>
<workspaces-menu v-if="panel === 'workspaces'"></workspaces-menu>
<workspaces-menu v-else-if="panel === 'workspaces'"></workspaces-menu>
<sync-menu v-else-if="panel === 'sync'"></sync-menu>
<publish-menu v-else-if="panel === 'publish'"></publish-menu>
<history-menu v-else-if="panel === 'history'"></history-menu>
@ -75,7 +75,11 @@ export default {
}),
computed: {
panel() {
return !this.$store.state.light && this.$store.getters['data/layoutSettings'].sideBarPanel;
if (this.$store.state.light) {
return null; // No menu in light mode
}
const result = this.$store.getters['data/layoutSettings'].sideBarPanel;
return panelNames[result] ? result : 'menu';
},
panelName() {
return panelNames[this.panel];
@ -164,7 +168,7 @@ export default {
padding: 10px;
margin: -10px -10px 10px;
background-color: $info-bg;
font-size: 0.95em;
font-size: 0.9em;
p {
margin: 10px;

View File

@ -64,7 +64,7 @@ export default {
const updateMaskY = () => {
const scrollPosition = editorSvc.getScrollPosition();
if (scrollPosition) {
const sectionDesc = editorSvc.previewCtx.sectionDescList[scrollPosition.sectionIdx];
const sectionDesc = editorSvc.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];
this.maskY = sectionDesc.tocDimension.startOffset +
(scrollPosition.posInSection * sectionDesc.tocDimension.height);
}

View File

@ -1,4 +1,4 @@
import cledit from '../../services/cledit';
import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc';
import utils from '../../services/utils';

View File

@ -1,4 +1,4 @@
import cledit from '../../services/cledit';
import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc';
import utils from '../../services/utils';

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import Clipboard from 'clipboard';
import timeSvc from '../../services/timeSvc';
import store from '../../store';
@ -32,10 +33,43 @@ Vue.directive('show', {
},
});
const setElTitle = (el, title) => {
el.title = title;
el.setAttribute('aria-label', title);
};
Vue.directive('title', {
bind(el, { value }) {
el.title = value;
el.setAttribute('aria-label', value);
setElTitle(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
setElTitle(el, value);
}
},
});
// Clipboard directive
const createClipboard = (el, value) => {
el.seClipboard = new Clipboard(el, { text: () => value });
};
const destroyClipboard = (el) => {
if (el.seClipboard) {
el.seClipboard.destroy();
el.seClipboard = null;
}
};
Vue.directive('clipboard', {
bind(el, { value }) {
createClipboard(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
destroyClipboard(el);
createClipboard(el, value);
}
},
unbind(el) {
destroyClipboard(el);
},
});

View File

@ -28,7 +28,7 @@
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import editorSvc from '../../services/editorSvc';
import animationSvc from '../../services/animationSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc';
@ -67,6 +67,9 @@ export default {
...mapMutations('discussion', [
'setCurrentDiscussionId',
]),
...mapActions('notification', [
'info',
]),
goToDiscussion(discussionId = this.currentDiscussionId) {
this.setCurrentDiscussionId(discussionId);
const layoutSettings = this.$store.getters['data/layoutSettings'];
@ -75,7 +78,7 @@ export default {
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
if (!coordinates) {
this.$store.dispatch('notification/info', "Discussion can't be located in the file.");
this.info("Discussion can't be located in the file.");
} else {
const scrollerElt = layoutSettings.showEditor
? editorSvc.editorElt.parentNode

View File

@ -24,7 +24,7 @@
import { mapGetters, mapMutations, mapActions } from 'vuex';
import Prism from 'prismjs';
import UserImage from '../UserImage';
import cledit from '../../services/cledit';
import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc';
import utils from '../../services/utils';

View File

@ -12,18 +12,19 @@
</menu-entry>
<menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download>
<div><div class="menu-entry__label">sponsor</div> Export as PDF</div>
<div><div class="menu-entry__label" :class="{'menu-entry__label--warning': !isSponsor}">sponsor</div> Export as PDF</div>
<span>Produce a PDF from an HTML template.</span>
</menu-entry>
<menu-entry @click.native="exportPandoc">
<icon-download slot="icon"></icon-download>
<div><div class="menu-entry__label">sponsor</div> Export with Pandoc</div>
<div><div class="menu-entry__label" :class="{'menu-entry__label--warning': !isSponsor}">sponsor</div> Export with Pandoc</div>
<span>Convert to PDF, Word, EPUB...</span>
</menu-entry>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import MenuEntry from './common/MenuEntry';
import exportSvc from '../../services/exportSvc';
@ -31,6 +32,7 @@ export default {
components: {
MenuEntry,
},
computed: mapGetters(['isSponsor']),
methods: {
exportMarkdown() {
const currentFile = this.$store.getters['file/current'];

View File

@ -169,7 +169,7 @@ export default {
created() {
// Find the workspace provider
const workspace = this.$store.getters['workspace/currentWorkspace'];
this.workspaceProvider = providerRegistry.providers[workspace.providerId];
this.workspaceProvider = providerRegistry.providersById[workspace.providerId];
// Watch file changes
this.$watch(

View File

@ -29,7 +29,7 @@ import htmlSanitizer from '../../libs/htmlSanitizer';
import MenuEntry from './common/MenuEntry';
import Provider from '../../services/providers/common/Provider';
import store from '../../store';
import fileSvc from '../../services/fileSvc';
import workspaceSvc from '../../services/workspaceSvc';
const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);
@ -56,7 +56,7 @@ export default {
async onImportMarkdown(evt) {
const file = evt.target.files[0];
const content = await readFile(file);
const item = await fileSvc.createFile({
const item = await workspaceSvc.createFile({
...Provider.parseContent(content),
name: file.name,
});
@ -67,7 +67,7 @@ export default {
const content = await readFile(file);
const sanitizedContent = htmlSanitizer.sanitizeHtml(content)
.replace(/&#160;/g, ' '); // Replace non-breaking spaces with classic spaces
const item = await fileSvc.createFile({
const item = await workspaceSvc.createFile({
...Provider.parseContent(turndownService.turndown(sanitizedContent)),
name: file.name,
});

View File

@ -1,6 +1,6 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="menu-info-entries">
<div class="side-bar__info">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="loginToken">
<div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="userId"></user-image>
@ -11,7 +11,15 @@
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
</div>
<span><b>{{currentWorkspace.name}}</b> synced.</span>
<span v-if="currentWorkspace.providerId === 'googleDriveAppData'">
<b>{{currentWorkspace.name}}</b> synced with your Google Drive app data folder.
</span>
<span v-else-if="currentWorkspace.providerId === 'googleDriveWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">Google Drive folder</a>.
</span>
<span v-else-if="currentWorkspace.providerId === 'couchdbWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">CouchDB database</a>.
</span>
</div>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
<div class="menu-entry__icon menu-entry__icon--disabled">
@ -27,7 +35,7 @@
</menu-entry>
<menu-entry @click.native="setPanel('workspaces')">
<icon-database slot="icon"></icon-database>
<div>Workspaces</div>
<div><div class="menu-entry__label menu-entry__label--warning">new</div> Workspaces</div>
<span>Switch to another workspace.</span>
</menu-entry>
<hr>
@ -83,6 +91,7 @@
<script>
import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './common/MenuEntry';
import providerRegistry from '../../services/providers/common/providerRegistry';
import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc';
@ -99,6 +108,10 @@ export default {
'loginToken',
'userId',
]),
currentWorkspaceUrl() {
const provider = providerRegistry.providersById[this.currentWorkspace.providerId];
return provider.getWorkspaceLocationUrl(this.currentWorkspace);
},
},
methods: {
...mapActions('data', {

View File

@ -16,7 +16,7 @@
</menu-entry>
<menu-entry @click.native="managePublish">
<icon-view-list slot="icon"></icon-view-list>
<div>File publication</div>
<div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> File publication</div>
<span>Manage current file publication locations.</span>
</menu-entry>
</div>
@ -113,8 +113,7 @@ import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
import publishSvc from '../../services/publishSvc';
import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens)
.map(sub => tokens[sub])
const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
.filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name));
@ -142,6 +141,9 @@ export default {
...mapGetters('publishLocation', {
publishLocations: 'current',
}),
locationCount() {
return Object.keys(this.publishLocations).length;
},
currentFileName() {
return this.$store.getters['file/current'].name;
},
@ -178,8 +180,10 @@ export default {
publishSvc.requestPublish();
}
},
managePublish() {
return this.$store.dispatch('modal/open', 'publishManagement');
async managePublish() {
try {
await this.$store.dispatch('modal/open', 'publishManagement');
} catch (e) { /* cancel */ }
},
async addGoogleDriveAccount() {
try {

View File

@ -1,7 +1,7 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="side-bar__info" v-if="isCurrentTemp">
<p><b>{{currentFileName}}</b> can not be synchronized as it's a temporary file.</p>
<p><b>{{currentFileName}}</b> can not be synced as it's a temporary file.</p>
</div>
<div v-else>
<div class="side-bar__info" v-if="noToken">
@ -16,7 +16,7 @@
</menu-entry>
<menu-entry @click.native="manageSync">
<icon-view-list slot="icon"></icon-view-list>
<div>File synchronization</div>
<div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> File synchronization</div>
<span>Manage current file synchronized locations.</span>
</menu-entry>
</div>
@ -91,8 +91,7 @@ import githubProvider from '../../services/providers/githubProvider';
import syncSvc from '../../services/syncSvc';
import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens)
.map(sub => tokens[sub])
const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
.filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name));
@ -116,8 +115,11 @@ export default {
'isCurrentTemp',
]),
...mapGetters('syncLocation', {
syncLocations: 'current',
syncLocations: 'currentWithWorkspaceSyncLocation',
}),
locationCount() {
return Object.keys(this.syncLocations).length;
},
currentFileName() {
return this.$store.getters['file/current'].name;
},
@ -142,8 +144,10 @@ export default {
syncSvc.requestSync();
}
},
manageSync() {
return this.$store.dispatch('modal/open', 'syncManagement');
async manageSync() {
try {
await this.$store.dispatch('modal/open', 'syncManagement');
} catch (e) { /* cancel */ }
},
async addGoogleDriveAccount() {
try {
@ -180,16 +184,12 @@ export default {
async saveGoogleDrive(token) {
try {
await openSyncModal(token, 'googleDriveSave');
} catch (e) {
// Cancel
}
} catch (e) { /* cancel */ }
},
async saveDropbox(token) {
try {
await openSyncModal(token, 'dropboxSave');
} catch (e) {
// Cancel
}
} catch (e) { /* cancel */ }
},
async openGithub(token) {
try {
@ -201,23 +201,17 @@ export default {
'queue/enqueue',
() => githubProvider.openFile(token, syncLocation),
);
} catch (e) {
// Cancel
}
} catch (e) { /* cancel */ }
},
async saveGithub(token) {
try {
await openSyncModal(token, 'githubSave');
} catch (e) {
// Cancel
}
} catch (e) { /* cancel */ }
},
async saveGist(token) {
try {
await openSyncModal(token, 'gistSync');
} catch (e) {
// Cancel
}
} catch (e) { /* cancel */ }
},
},
};

View File

@ -1,6 +1,6 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="workspace" v-for="(workspace, id) in sanitizedWorkspacesById" :key="id">
<div class="workspace" v-for="(workspace, id) in workspacesById" :key="id">
<menu-entry :href="workspace.url" target="_blank">
<icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider>
<div class="workspace__name"><div class="menu-entry__label" v-if="currentWorkspace === workspace">current</div>{{workspace.name}}</div>
@ -9,19 +9,22 @@
<hr>
<menu-entry @click.native="addCouchdbWorkspace">
<icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider>
<span>Add CouchDB workspace</span>
<div>CouchDB workspace</div>
<span>Add a workspace synced with your CouchDB database.</span>
</menu-entry>
<menu-entry @click.native="addGithubWorkspace">
<icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider>
<span>Add GitHub workspace</span>
<div>GitHub workspace</div>
<span>Add a workspace synced with a GitHub repository.</span>
</menu-entry>
<menu-entry @click.native="addGoogleDriveWorkspace">
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span>
<div>Google Drive workspace</div>
<span>Add a workspace synced with a Google Drive folder.</span>
</menu-entry>
<menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database>
<span>Manage workspaces</span>
<span><div class="menu-entry__label menu-entry__label--count">{{workspaceCount}}</div> Manage workspaces</span>
</menu-entry>
</div>
</template>
@ -36,12 +39,13 @@ export default {
MenuEntry,
},
computed: {
...mapGetters('data', [
'sanitizedWorkspaceById',
]),
...mapGetters('workspace', [
'workspacesById',
'currentWorkspace',
]),
workspaceCount() {
return Object.keys(this.workspacesById).length;
},
},
methods: {
async addCouchdbWorkspace() {

View File

@ -24,9 +24,13 @@
span {
display: inline-block;
font-size: 0.75rem;
opacity: 0.5;
opacity: 0.6;
line-height: 1.3;
.menu-entry__label {
opacity: 1;
}
span {
display: inline;
opacity: 1;
@ -34,12 +38,6 @@
}
}
.menu-info-entries {
padding: 10px;
margin: -10px -10px 10px;
background-color: rgba(255, 255, 255, 0.2);
}
.menu-entry--info {
padding-top: 3px;
padding-bottom: 3px;
@ -70,10 +68,22 @@
float: right;
font-size: 0.6rem;
font-weight: 600;
padding: 0.05em 0.25em;
background-color: darken($error-color, 10);
line-height: 1;
padding: 0.15em 0.25em;
background-color: #fff;
border-radius: 3px;
opacity: 0.6;
}
.menu-entry__label--warning {
color: #fff;
background-color: darken($error-color, 10);
opacity: 1;
}
.menu-entry__label--count {
font-size: 0.75rem;
font-weight: 400;
}
.menu-entry__text {

View File

@ -24,7 +24,7 @@
<a target="_blank" href="privacy_policy.html">Privacy Policy</a>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
<button class="button button--resolve" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template>

View File

@ -78,7 +78,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -14,15 +14,15 @@
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button button--copy">Copy to clipboard</button>
<button class="button button--copy" v-clipboard="result" @click="info('HTML copied to clipboard!')">Copy</button>
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import Clipboard from 'clipboard';
import { mapActions } from 'vuex';
import exportSvc from '../../services/exportSvc';
import modalTemplate from './common/modalTemplate';
@ -48,14 +48,11 @@ export default modalTemplate({
}, {
immediate: true,
});
this.clipboard = new Clipboard(this.$el.querySelector('.button--copy'), {
text: () => this.result,
});
},
destroyed() {
this.clipboard.destroy();
},
methods: {
...mapActions('notification', [
'info',
]),
resolve() {
const { config } = this;
const currentFile = this.$store.getters['file/current'];

View File

@ -17,7 +17,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -36,9 +36,8 @@ export default modalTemplate({
}),
computed: {
googlePhotosTokens() {
const googleTokens = this.$store.getters['data/googleTokensBySub'];
return Object.entries(googleTokens)
.map(([, token]) => token)
const googleTokensBySub = this.$store.getters['data/googleTokensBySub'];
return Object.values(googleTokensBySub)
.filter(token => token.isPhotos)
.sort((token1, token2) => token1.name.localeCompare(token2.name));
},

View File

@ -8,7 +8,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -20,7 +20,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -15,7 +15,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -1,6 +1,9 @@
<template>
<modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations">
<div class="modal__content">
<div class="modal__image">
<icon-upload></icon-upload>
</div>
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not published yet.</p>
<div>
@ -12,21 +15,21 @@
{{location.description}}
</div>
<div class="publish-entry__buttons flex flex--row flex--center">
<a class="publish-entry__button button" :href="location.url" target="_blank">
<a class="publish-entry__button button" :href="location.url" target="_blank" v-title="'Open location'">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="publish-entry__button button" @click="remove(location)">
<button class="publish-entry__button button" @click="remove(location)" v-title="'Remove location'">
<icon-delete></icon-delete>
</button>
</div>
</div>
</div>
<div class="modal__info" v-if="publishLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file.
<b>Tip:</b> Removing a location won't delete any file.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
<button class="button button--resolve" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template>

View File

@ -25,7 +25,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
<button class="button button--resolve" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -1,10 +1,14 @@
<template>
<modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations">
<div class="modal__content">
<div class="modal__image">
<icon-sync></icon-sync>
</div>
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
<div>
<div class="sync-entry flex flex--row flex--align-center" v-for="location in syncLocations" :key="location.id">
<div class="sync-entry flex flex--column" v-for="location in syncLocations" :key="location.id">
<div class="sync-entry__header flex flex--row flex--align-center">
<div class="sync-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider>
</div>
@ -12,27 +16,38 @@
{{location.description}}
</div>
<div class="sync-entry__buttons flex flex--row flex--center">
<a class="sync-entry__button button" :href="location.url" target="_blank">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="sync-entry__button button" @click="remove(location)">
<button class="sync-entry__button button" @click="remove(location)" v-title="'Remove location'">
<icon-delete></icon-delete>
</button>
</div>
</div>
<div class="sync-entry__row flex flex--row flex--align-center">
<div class="sync-entry__url">
{{location.url || 'Workspace location'}}
</div>
<div class="sync-entry__buttons flex flex--row flex--center" v-if="location.url">
<button class="sync-entry__button button" v-clipboard="location.url" @click="info('Location URL copied to clipboard!')" v-title="'Copy URL'">
<icon-content-copy></icon-content-copy>
</button>
<a class="sync-entry__button button" v-if="location.url" :href="location.url" target="_blank" v-title="'Open location'">
<icon-open-in-new></icon-open-in-new>
</a>
</div>
</div>
</div>
</div>
<div class="modal__info" v-if="syncLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file.
<b>Tip:</b> Removing a location won't delete any file.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
<button class="button button--resolve" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import ModalInner from './common/ModalInner';
export default {
@ -44,15 +59,22 @@ export default {
'config',
]),
...mapGetters('syncLocation', {
syncLocations: 'current',
syncLocations: 'currentWithWorkspaceSyncLocation',
}),
currentFileName() {
return this.$store.getters['file/current'].name;
},
},
methods: {
...mapActions('notification', [
'info',
]),
remove(location) {
if (location.id === 'main') {
this.info('This location can not be removed.');
} else {
this.$store.commit('syncLocation/deleteItem', location.id);
}
},
},
};
@ -66,44 +88,75 @@ export default {
}
.sync-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid $hr-color;
&:last-child {
border-bottom: none;
margin: 1.5em 0;
height: auto;
font-size: 17px;
line-height: 1.5;
}
$button-size: 30px;
$small-button-size: 22px;
.sync-entry__header {
line-height: $button-size;
}
.sync-entry__row {
margin-top: 1px;
padding-top: 1px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
line-height: $small-button-size;
}
.sync-entry__icon {
height: 30px;
width: 30px;
height: 22px;
width: 22px;
margin-right: 0.75rem;
flex: none;
}
.sync-entry__description {
opacity: 0.5;
line-height: 1.4;
font-size: 0.9em;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sync-entry__url {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0.5;
font-size: 0.67em;
}
.sync-entry__buttons {
margin-left: 0.75rem;
.sync-entry__row & {
margin-left: 0.5rem;
}
}
.sync-entry__button {
width: 38px;
height: 38px;
padding: 6px;
width: $button-size;
height: $button-size;
padding: 4px;
background-color: transparent;
opacity: 0.75;
.sync-entry__row & {
width: $small-button-size;
height: $small-button-size;
padding: 4px;
}
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -45,7 +45,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -1,39 +1,69 @@
<template>
<modal-inner class="modal__inner-1--workspace-management" aria-label="Manage workspaces">
<div class="modal__content">
<div class="workspace-entry flex flex--row flex--align-center" v-for="(workspace, id) in sanitizedWorkspacesById" :key="id">
<div class="workspace-entry__icon flex flex--column flex--center">
<div class="modal__image">
<icon-database></icon-database>
</div>
<p>The following workspaces are locally available:</p>
<div class="workspace-entry flex flex--column" v-for="(workspace, id) in workspacesById" :key="id">
<div class="flex flex--column">
<div class="workspace-entry__header flex flex--row flex--align-center">
<div class="workspace-entry__icon">
<icon-provider :provider-id="workspace.providerId"></icon-provider>
</div>
<div class="workspace-entry__description flex flex--column">
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingName">
<div class="workspace-entry__name" v-else>
{{workspace.name}}
</div>
<div class="workspace-entry__url">
{{workspace.url}}
</div>
</div>
<div class="workspace-entry__buttons flex flex--row flex--center">
<button class="workspace-entry__button button" @click="edit(id)">
<div class="workspace-entry__name" v-else>{{workspace.name}}</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" @click="edit(id)" v-title="'Edit name'">
<icon-pen></icon-pen>
</button>
<button class="workspace-entry__button button" v-if="id !== currentWorkspace.id && id !== mainWorkspace.id" @click="remove(id)">
<button class="workspace-entry__button button" @click="remove(id)" v-title="'Remove'">
<icon-delete></icon-delete>
</button>
</div>
</div>
<div class="workspace-entry__row flex flex--row flex--align-center">
<div class="workspace-entry__url">
{{workspace.url}}
</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" v-clipboard="workspace.url" @click="info('Workspace URL copied to clipboard!')" v-title="'Copy URL'">
<icon-content-copy></icon-content-copy>
</button>
<a class="workspace-entry__button button" :href="workspace.url" target="_blank" v-title="'Open workspace'">
<icon-open-in-new></icon-open-in-new>
</a>
</div>
</div>
<div class="workspace-entry__row flex flex--row flex--align-center" v-if="workspace.locationUrl">
<div class="workspace-entry__url">
{{workspace.locationUrl}}
</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" v-clipboard="workspace.locationUrl" @click="info('Workspace URL copied to clipboard!')" v-title="'Copy URL'">
<icon-content-copy></icon-content-copy>
</button>
<a class="workspace-entry__button button" :href="workspace.locationUrl" target="_blank" v-title="'Open workspace location'">
<icon-open-in-new></icon-open-in-new>
</a>
</div>
</div>
</div>
</div>
<div class="modal__info">
<b>ProTip:</b> Workspaces are accessible <b>offline</b> after their first use.
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
<button class="button button--resolve" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import { mapGetters, mapActions } from 'vuex';
import ModalInner from './common/ModalInner';
import localDbSvc from '../../services/localDbSvc';
import workspaceSvc from '../../services/workspaceSvc';
export default {
components: {
@ -47,24 +77,25 @@ export default {
...mapGetters('modal', [
'config',
]),
...mapGetters('data', [
'workspacesById',
'sanitizedWorkspacesById',
]),
...mapGetters('workspace', [
'workspacesById',
'mainWorkspace',
'currentWorkspace',
]),
},
methods: {
...mapActions('notification', [
'info',
]),
edit(id) {
this.editedId = id;
this.editingName = this.workspacesById[id].name;
},
submitEdit(cancel) {
const workspace = this.workspacesById[this.editedId];
if (workspace && !cancel && this.editingName) {
this.$store.dispatch('data/patchWorkspacesById', {
if (workspace) {
if (!cancel && this.editingName) {
this.$store.dispatch('workspace/patchWorkspacesById', {
[this.editedId]: {
...workspace,
name: this.editingName,
@ -73,14 +104,19 @@ export default {
} else {
this.editingName = workspace.name;
}
}
this.editedId = null;
},
async remove(id) {
if (id === this.mainWorkspace.id) {
this.info('Your main workspace can not be removed.');
} else if (id === this.currentWorkspace.id) {
this.info('Please close the workspace before removing it.');
} else {
try {
await this.$store.dispatch('modal/open', 'removeWorkspace');
localDbSvc.removeWorkspace(id);
} catch (e) {
// Cancel
workspaceSvc.removeWorkspace(id);
} catch (e) { /* Cancel */ }
}
},
},
@ -95,58 +131,78 @@ export default {
}
.workspace-entry {
text-align: left;
padding-left: 10px;
margin: 15px 0;
margin: 1.75em 0;
height: auto;
font-size: 17px;
line-height: 1.5;
text-transform: none;
&:last-child {
border-bottom: none;
}
span {
text-overflow: ellipsis;
overflow: hidden;
$button-size: 30px;
$small-button-size: 22px;
.workspace-entry__header {
line-height: $button-size;
.text-input {
border: 1px solid $link-color;
padding: 0 5px;
line-height: $button-size;
height: $button-size;
}
}
.workspace-entry__row {
margin-top: 1px;
padding-top: 1px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
line-height: $small-button-size;
}
.workspace-entry__icon {
height: 20px;
width: 20px;
margin-right: 12px;
height: 22px;
width: 22px;
margin-right: 0.75rem;
flex: none;
}
.workspace-entry__description {
width: 100%;
word-wrap: break-word;
overflow: hidden;
}
.workspace-entry__name {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: bold;
}
.workspace-entry__url {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0.5;
font-size: 0.75em;
font-size: 0.67em;
}
.workspace-entry__buttons {
margin-left: 0.75rem;
.workspace-entry__row & {
margin-left: 0.5rem;
}
}
.workspace-entry__button {
width: 36px;
height: 36px;
padding: 6px;
width: $button-size;
height: $button-size;
padding: 4px;
background-color: transparent;
opacity: 0.75;
.workspace-entry__row & {
width: $small-button-size;
height: $small-button-size;
padding: 4px;
}
&:active,
&:focus,
&:hover {

View File

@ -30,7 +30,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -31,7 +31,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -14,7 +14,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -4,20 +4,20 @@
<div class="modal__image">
<icon-provider provider-id="couchdb"></icon-provider>
</div>
<p>Create a workspace synchronized with a <b>CouchDB</b> database.</p>
<p>Create a workspace synced with a <b>CouchDB</b> database.</p>
<form-entry label="Database URL" error="dbUrl">
<input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> https://instance.smileupps.com/stackedit-workspace
</div>
<div class="form-entry__actions">
<a href="https://community.stackedit.io/t/couchdb-workspace-setup/" target="_blank">More info</a>
<a href="https://community.stackedit.io/t/couchdb-workspace-setup/" target="_blank">How to setup?</a>
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -18,7 +18,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
<button class="button button--resolve" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -25,7 +25,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
<p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synchronized.</p>
<p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synced.</p>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -15,7 +15,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -37,7 +37,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="gist"></icon-provider>
</div>
<p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synchronized.</p>
<p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synced.</p>
<form-entry label="Filename" error="filename">
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
</form-entry>
@ -24,7 +24,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -15,7 +15,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
<button class="button button--resolve" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>Open a file from your <b>GitHub</b> repository and keep it synchronized.</p>
<p>Open a file from your <b>GitHub</b> repository and keep it synced.</p>
<form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -26,7 +26,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -34,6 +34,7 @@
<script>
import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
@ -52,7 +53,7 @@ export default modalTemplate({
this.setError('path');
}
if (this.repoUrl && this.path) {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {

View File

@ -37,7 +37,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synchronized.</p>
<p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synced.</p>
<form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -27,7 +27,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -35,6 +35,7 @@
<script>
import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
@ -49,7 +50,7 @@ export default modalTemplate({
},
methods: {
resolve() {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
}

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>Create a workspace synchronized with a <b>GitHub</b> repository folder.</p>
<p>Create a workspace synced with a <b>GitHub</b> repository folder.</p>
<form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -26,13 +26,12 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import githubProvider from '../../../services/providers/githubProvider';
import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate';
@ -46,14 +45,14 @@ export default modalTemplate({
},
methods: {
resolve() {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {
const path = this.path && this.path.replace(/^\//, '');
const url = utils.addQueryParams('app', {
...parsedRepo,
providerId: 'githubWorkspace',
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
branch: this.branch || 'master',
path: path || undefined,
}, true);

View File

@ -18,7 +18,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
<button class="button button--resolve" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -48,7 +48,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -73,9 +73,11 @@ export default modalTemplate({
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
if (folders[0]) {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id,
});
}
}),
);
},

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synchronized.</p>
<p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synced.</p>
<form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -23,7 +23,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -46,9 +46,11 @@ export default modalTemplate({
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
if (folders[0]) {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id,
});
}
}),
);
},

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>Create a workspace synchronized with a <b>Google Drive</b> folder.</p>
<p>Create a workspace synced with a <b>Google Drive</b> folder.</p>
<form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -17,7 +17,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
@ -37,9 +37,11 @@ export default modalTemplate({
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
if (folders[0]) {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveWorkspaceFolderId: folders[0].id,
});
}
}),
);
},

View File

@ -11,7 +11,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -33,7 +33,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -21,7 +21,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -37,7 +37,7 @@
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>

View File

@ -1,6 +1,6 @@
export default () => ({
main: {
name: 'Main workspace',
// The rest will be filled by the data/sanitizedWorkspacesById getter
// The rest will be filled by the workspace/workspacesById getter
},
});

View File

@ -4,14 +4,6 @@ If your workspace is not synced, your files are only stored inside your browser
We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared.
**Where is my data stored once I sync my workspace?**
If you sign in with Google, your main workspace will be stored in Google Drive, in your [app data folder](https://developers.google.com/drive/v3/web/appdata).
If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users.
If you open a CouchDB workspace, the files in the workspace will be stored in the CouchDB database which can be hosted on premises for privacy concerns.
**Can StackEdit access my data without telling me?**
StackEdit is a frontend application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any backend or 3^rd^ parties so your data won't be accessed by anyone.

View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M 19,21L 8,21L 8,7L 19,7M 19,5L 8,5C 6.9,5 6,5.9 6,7L 6,21C 6,22.1 6.9,23 8,23L 19,23C 20.1,23 21,22.1 21,21L 21,7C 21,5.9 20.1,5 19,5 Z M 16,1L 4,1C 2.9,1 2,1.9 2,3L 2,17L 4,17L 4,3L 16,3L 16,1 Z " />
</svg>
</template>

View File

@ -50,6 +50,7 @@ import Database from './Database';
import Magnify from './Magnify';
import FormatListChecks from './FormatListChecks';
import CloseCircle from './CloseCircle';
import ContentCopy from './ContentCopy';
Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold);
@ -102,3 +103,4 @@ Vue.component('iconDatabase', Database);
Vue.component('iconMagnify', Magnify);
Vue.component('iconFormatListChecks', FormatListChecks);
Vue.component('iconCloseCircle', CloseCircle);
Vue.component('iconContentCopy', ContentCopy);

View File

@ -1,4 +1,4 @@
import fileSvc from './fileSvc';
import workspaceSvc from './workspaceSvc';
import utils from './utils';
export default {
@ -51,7 +51,7 @@ export default {
await utils.awaitSequence(
Object.keys(folderNameMap),
async externalId => fileSvc.setOrPatchItem({
async externalId => workspaceSvc.setOrPatchItem({
id: folderIdMap[externalId],
type: 'folder',
name: folderNameMap[externalId],
@ -61,7 +61,7 @@ export default {
await utils.awaitSequence(
Object.keys(fileNameMap),
async externalId => fileSvc.createFile({
async externalId => workspaceSvc.createFile({
name: fileNameMap[externalId],
parentId: folderIdMap[parentIdMap[externalId]],
text: textMap[externalId],

View File

@ -1,7 +1,7 @@
import DiffMatchPatch from 'diff-match-patch';
import TurndownService from 'turndown/lib/turndown.browser.umd';
import htmlSanitizer from '../../libs/htmlSanitizer';
import store from '../../store';
import htmlSanitizer from '../../../libs/htmlSanitizer';
import store from '../../../store';
function cledit(contentElt, scrollEltOpt, isMarkdown = false) {
const scrollElt = scrollEltOpt || contentElt;

View File

@ -29,10 +29,14 @@ function SelectionMgr(editor) {
this.createRange = (start, end) => {
const range = document.createRange();
const startContainer = Number.isNaN(start) ? start : this.findContainer(start < 0 ? 0 : start);
const startContainer = typeof start === 'number'
? this.findContainer(start < 0 ? 0 : start)
: start;
let endContainer = startContainer;
if (start !== end) {
endContainer = Number.isNaN(end) ? end : this.findContainer(end < 0 ? 0 : end);
endContainer = typeof end === 'number'
? this.findContainer(end < 0 ? 0 : end)
: end;
}
range.setStart(startContainer.container, startContainer.offsetInContainer);
range.setEnd(endContainer.container, endContainer.offsetInContainer);

View File

@ -1,4 +1,4 @@
import '../../libs/clunderscore';
import '../../../libs/clunderscore';
import cledit from './cleditCore';
import './cleditHighlighter';
import './cleditKeystroke';

View File

@ -1,10 +1,10 @@
import DiffMatchPatch from 'diff-match-patch';
import cledit from './cledit';
import utils from './utils';
import diffUtils from './diffUtils';
import store from '../store';
import EditorClassApplier from '../components/common/EditorClassApplier';
import PreviewClassApplier from '../components/common/PreviewClassApplier';
import utils from '../utils';
import diffUtils from '../diffUtils';
import store from '../../store';
import EditorClassApplier from '../../components/common/EditorClassApplier';
import PreviewClassApplier from '../../components/common/PreviewClassApplier';
let clEditor;
// let discussionIds = {};

View File

@ -1,7 +1,7 @@
import DiffMatchPatch from 'diff-match-patch';
import cledit from './cledit';
import animationSvc from './animationSvc';
import store from '../store';
import animationSvc from '../animationSvc';
import store from '../../store';
const diffMatchPatch = new DiffMatchPatch();

View File

@ -0,0 +1,114 @@
class SectionDimension {
constructor(startOffset, endOffset) {
this.startOffset = startOffset;
this.endOffset = endOffset;
this.height = endOffset - startOffset;
}
}
const dimensionNormalizer = dimensionName => (editorSvc) => {
const dimensionList = editorSvc.previewCtx.sectionDescList
.map(sectionDesc => sectionDesc[dimensionName]);
let dimension;
let i;
let j;
for (i = 0; i < dimensionList.length; i += 1) {
dimension = dimensionList[i];
if (dimension.height) {
for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) {
// Loop
}
const normalizeFactor = j - i;
if (normalizeFactor !== 1) {
const normalizedHeight = dimension.height / normalizeFactor;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
for (j = i + 1; j < i + normalizeFactor; j += 1) {
const startOffset = dimension.endOffset;
dimension = dimensionList[j];
dimension.startOffset = startOffset;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
}
i = j - 1;
}
}
}
};
const normalizeEditorDimensions = dimensionNormalizer('editorDimension');
const normalizePreviewDimensions = dimensionNormalizer('previewDimension');
const normalizeTocDimensions = dimensionNormalizer('tocDimension');
export default {
measureSectionDimensions(editorSvc) {
let editorSectionOffset = 0;
let previewSectionOffset = 0;
let tocSectionOffset = 0;
let sectionDesc = editorSvc.previewCtx.sectionDescList[0];
let nextSectionDesc;
let i = 1;
for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) {
nextSectionDesc = editorSvc.previewCtx.sectionDescList[i];
// Measure editor section
let newEditorSectionOffset = nextSectionDesc.editorElt
? nextSectionDesc.editorElt.offsetTop
: editorSectionOffset;
newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset
? newEditorSectionOffset
: editorSectionOffset;
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
newEditorSectionOffset,
);
editorSectionOffset = newEditorSectionOffset;
// Measure preview section
let newPreviewSectionOffset = nextSectionDesc.previewElt
? nextSectionDesc.previewElt.offsetTop
: previewSectionOffset;
newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset
? newPreviewSectionOffset
: previewSectionOffset;
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
newPreviewSectionOffset,
);
previewSectionOffset = newPreviewSectionOffset;
// Measure TOC section
let newTocSectionOffset = nextSectionDesc.tocElt
? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2)
: tocSectionOffset;
newTocSectionOffset = newTocSectionOffset > tocSectionOffset
? newTocSectionOffset
: tocSectionOffset;
sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset);
tocSectionOffset = newTocSectionOffset;
sectionDesc = nextSectionDesc;
}
// Last section
sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1];
if (sectionDesc) {
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
editorSvc.editorElt.scrollHeight,
);
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
editorSvc.previewElt.scrollHeight,
);
sectionDesc.tocDimension = new SectionDimension(
tocSectionOffset,
editorSvc.tocElt.scrollHeight,
);
}
normalizeEditorDimensions(editorSvc);
normalizePreviewDimensions(editorSvc);
normalizeTocDimensions(editorSvc);
},
};

View File

@ -2,15 +2,15 @@ import Vue from 'vue';
import DiffMatchPatch from 'diff-match-patch';
import Prism from 'prismjs';
import markdownItPandocRenderer from 'markdown-it-pandoc-renderer';
import cledit from './cledit';
import cledit from './editor/cledit';
import pagedown from '../libs/pagedown';
import htmlSanitizer from '../libs/htmlSanitizer';
import markdownConversionSvc from './markdownConversionSvc';
import markdownGrammarSvc from './markdownGrammarSvc';
import sectionUtils from './sectionUtils';
import sectionUtils from './editor/sectionUtils';
import extensionSvc from './extensionSvc';
import editorSvcDiscussions from './editorSvcDiscussions';
import editorSvcUtils from './editorSvcUtils';
import editorSvcDiscussions from './editor/editorSvcDiscussions';
import editorSvcUtils from './editor/editorSvcUtils';
import utils from './utils';
import store from '../store';

View File

@ -1,5 +1,5 @@
import store from '../store';
import fileSvc from './fileSvc';
import workspaceSvc from './workspaceSvc';
export default {
newItem(isFolder = false) {
@ -59,7 +59,7 @@ export default {
parentId: 'trash',
});
} else {
fileSvc.deleteFile(id);
workspaceSvc.deleteFile(id);
}
};

View File

@ -2,7 +2,7 @@ import FileSaver from 'file-saver';
import utils from './utils';
import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
import fileSvc from './fileSvc';
import workspaceSvc from './workspaceSvc';
const dbVersion = 1;
const dbStoreName = 'objects';
@ -12,21 +12,13 @@ const resetApp = utils.queryParams.reset;
const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
const getDbName = (workspaceId) => {
let dbName = 'stackedit-db';
if (workspaceId !== 'main') {
dbName += `-${workspaceId}`;
}
return dbName;
};
class Connection {
constructor() {
this.getTxCbs = [];
// Make the DB name
const workspaceId = store.getters['workspace/currentWorkspace'].id;
this.dbName = getDbName(workspaceId);
this.dbName = utils.getDbName(workspaceId);
// Init connection
const request = indexedDB.open(this.dbName, dbVersion);
@ -142,9 +134,12 @@ const localDbSvc = {
this.connection.createTx((tx) => {
// Look for DB changes and apply them to the store
this.readAll(tx, (storeItemMap) => {
// Sanitize the workspace
workspaceSvc.ensureUniquePaths();
workspaceSvc.ensureUniqueLocations();
// Persist all the store changes into the DB
this.writeAll(storeItemMap, tx);
// Sync localStorage
// Sync the localStorage
this.syncLocalStorage();
// Done
resolve();
@ -177,7 +172,10 @@ const localDbSvc = {
// Collect change
changes.push(item);
cursor.continue();
} else {
return;
}
// Read the collected changes
const storeItemMap = { ...store.getters.allItemsById };
changes.forEach((item) => {
this.readDbItem(item, storeItemMap);
@ -186,10 +184,9 @@ const localDbSvc = {
dbStore.delete(item.id);
}
});
fileSvc.ensureUniquePaths();
this.lastTx = lastTx;
cb(storeItemMap);
}
};
},
@ -322,40 +319,20 @@ const localDbSvc = {
});
},
/**
* Drop the database and clean the localStorage for the specified workspaceId.
*/
async removeWorkspace(id) {
const workspacesById = {
...store.getters['data/workspacesById'],
};
delete workspacesById[id];
store.dispatch('data/setWorkspacesById', workspacesById);
this.syncLocalStorage();
await new Promise((resolve, reject) => {
const dbName = getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = reject;
request.onsuccess = resolve;
});
localStorage.removeItem(`${id}/lastSyncActivity`);
localStorage.removeItem(`${id}/lastWindowFocus`);
},
/**
* Create the connection and start syncing.
*/
async init() {
// Reset the app if reset flag was passed
if (resetApp) {
await Promise.all(Object.keys(store.getters['data/workspacesById'])
.map(workspaceId => localDbSvc.removeWorkspace(workspaceId)));
await Promise.all(Object.keys(store.getters['workspace/workspacesById'])
.map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));
utils.localStorageDataIds.forEach((id) => {
// Clean data stored in localStorage
localStorage.removeItem(`data/${id}`);
});
window.location.reload();
throw new Error('reload');
throw new Error('RELOAD');
}
// Create the connection
@ -374,6 +351,13 @@ const localDbSvc = {
return;
}
// Watch workspace deletions and persist them as soon as possible
// to make the changes available to reloading workspace tabs.
store.watch(
() => store.getters['data/workspaces'],
() => this.syncLocalStorage(),
);
// Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile);
const { welcomeFileHashes } = store.getters['data/localSettings'];
@ -393,7 +377,7 @@ const localDbSvc = {
// Clean files
store.getters['file/items']
.filter(file => file.parentId === 'trash') // If file is in the trash
.forEach(file => fileSvc.deleteFile(file.id));
.forEach(file => workspaceSvc.deleteFile(file.id));
}
// Enable sponsorship
@ -429,7 +413,7 @@ const localDbSvc = {
store.commit('file/setCurrentId', recentFile.id);
} else {
// If still no ID, create a new file
const newFile = await fileSvc.createFile({
const newFile = await workspaceSvc.createFile({
name: 'Welcome file',
text: welcomeFile,
}, true);

View File

@ -1,4 +1,4 @@
import cledit from '../cledit';
import cledit from '../editor/cledit';
import editorSvc from '../editorSvc';
import store from '../../store';

View File

@ -4,16 +4,15 @@ import Provider from './common/Provider';
export default new Provider({
id: 'bloggerPage',
getToken(location) {
const token = store.getters['data/googleTokensBySub'][location.sub];
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null;
},
getUrl(location) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=page;pageID=${location.pageId}`;
getLocationUrl({ blogId, pageId }) {
return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=page;pageID=${pageId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.pageId}${location.blogUrl}${token.name}`;
getLocationDescription({ pageId }) {
return pageId;
},
async publish(token, html, metadata, publishLocation) {
const page = await googleHelper.uploadBlogger({

View File

@ -4,16 +4,15 @@ import Provider from './common/Provider';
export default new Provider({
id: 'blogger',
getToken(location) {
const token = store.getters['data/googleTokensBySub'][location.sub];
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null;
},
getUrl(location) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=post;postID=${location.postId}`;
getLocationUrl({ blogId, postId }) {
return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=post;postID=${postId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.postId}${location.blogUrl}${token.name}`;
getLocationDescription({ postId }) {
return postId;
},
async publish(token, html, metadata, publishLocation) {
const post = await googleHelper.uploadBlogger({

View File

@ -2,7 +2,7 @@ import providerRegistry from './providerRegistry';
import emptyContent from '../../../data/emptyContent';
import utils from '../../utils';
import store from '../../../store';
import fileSvc from '../../fileSvc';
import workspaceSvc from '../../workspaceSvc';
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/;
@ -81,8 +81,8 @@ export default class Provider {
/**
* Find and open a file with location that meets the criteria
*/
static openFileWithLocation(allLocations, criteria) {
const location = utils.search(allLocations, criteria);
static openFileWithLocation(criteria) {
const location = utils.search(store.getters['syncLocation/items'], criteria);
if (location) {
// Found one, open it if it exists
const item = store.state.file.itemsById[location.fileId];
@ -90,7 +90,7 @@ export default class Provider {
store.commit('file/setCurrentId', item.id);
// If file is in the trash, restore it
if (item.parentId === 'trash') {
fileSvc.setOrPatchItem({
workspaceSvc.setOrPatchItem({
...item,
parentId: null,
});

View File

@ -1,7 +1,7 @@
export default {
providers: {},
providersById: {},
register(provider) {
this.providers[provider.id] = provider;
this.providersById[provider.id] = provider;
return provider;
},
};

View File

@ -10,35 +10,41 @@ export default new Provider({
getToken() {
return store.getters['workspace/syncToken'];
},
async initWorkspace() {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing /
const workspaceParams = {
getWorkspaceParams({ dbUrl }) {
return {
providerId: this.id,
dbUrl,
};
},
getWorkspaceLocationUrl({ dbUrl }) {
return dbUrl;
},
getSyncDataUrl(fileSyncData, { id }) {
const { dbUrl } = this.getToken();
return `${dbUrl}/${id}/data`;
},
getSyncDataDescription(fileSyncData, { id }) {
return id;
},
async initWorkspace() {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing /
const workspaceParams = this.getWorkspaceParams({ dbUrl });
const workspaceId = utils.makeWorkspaceId(workspaceParams);
const getToken = () => store.getters['data/couchdbTokensBySub'][workspaceId];
const getWorkspace = () => store.getters['data/sanitizedWorkspacesById'][workspaceId];
if (!getToken()) {
// Create token
// Create the token if it doesn't exist
if (!store.getters['data/couchdbTokensBySub'][workspaceId]) {
store.dispatch('data/addCouchdbToken', {
sub: workspaceId,
dbUrl,
});
}
// Create the workspace
let workspace = getWorkspace();
if (!workspace) {
// Make sure the database exists and retrieve its name
let db;
// Create the workspace if it doesn't exist
if (!store.getters['workspace/workspacesById'][workspaceId]) {
try {
db = await couchdbHelper.getDb(getToken());
} catch (e) {
throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`);
}
store.dispatch('data/patchWorkspacesById', {
// Make sure the database exists and retrieve its name
const db = await couchdbHelper.getDb(store.getters['data/couchdbTokensBySub'][workspaceId]);
store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: {
id: workspaceId,
name: db.db_name,
@ -46,20 +52,12 @@ export default new Provider({
dbUrl,
},
});
workspace = getWorkspace();
} catch (e) {
throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`);
}
}
// Fix the URL hash
utils.setQueryParams(workspaceParams);
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspace.id]: {
...workspace,
url: window.location.href,
},
});
}
return getWorkspace();
return store.getters['workspace/workspacesById'][workspaceId];
},
async getChanges() {
const syncToken = store.getters['workspace/syncToken'];

View File

@ -2,7 +2,7 @@ import store from '../../store';
import dropboxHelper from './helpers/dropboxHelper';
import Provider from './common/Provider';
import utils from '../utils';
import fileSvc from '../fileSvc';
import workspaceSvc from '../workspaceSvc';
const makePathAbsolute = (token, path) => {
if (!token.fullAccess) {
@ -19,17 +19,16 @@ const makePathRelative = (token, path) => {
export default new Provider({
id: 'dropbox',
getToken(location) {
return store.getters['data/dropboxTokensBySub'][location.sub];
getToken({ sub }) {
return store.getters['data/dropboxTokensBySub'][sub];
},
getUrl(location) {
const pathComponents = location.path.split('/').map(encodeURIComponent);
getLocationUrl({ path }) {
const pathComponents = path.split('/').map(encodeURIComponent);
const filename = pathComponents.pop();
return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.path}${location.dropboxFileId}${token.name}`;
getLocationDescription({ path }) {
return path;
},
checkPath(path) {
return path && path.match(/^\/[^\\<>:"|?*]+$/);
@ -71,7 +70,7 @@ export default new Provider({
async openFiles(token, paths) {
await utils.awaitSequence(paths, async (path) => {
// Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], {
if (!Provider.openFileWithLocation({
providerId: this.id,
path,
})) {
@ -99,7 +98,7 @@ export default new Provider({
if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos);
}
const item = await fileSvc.createFile({
const item = await workspaceSvc.createFile({
name,
parentId: store.getters['file/current'].parentId,
text: content.text,
@ -108,9 +107,8 @@ export default new Provider({
comments: content.comments,
}, true);
store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', {
workspaceSvc.addSyncLocation({
...syncLocation,
id: utils.uid(),
fileId: item.id,
});
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`);

View File

@ -5,15 +5,14 @@ import utils from '../utils';
export default new Provider({
id: 'gist',
getToken(location) {
return store.getters['data/githubTokensBySub'][location.sub];
getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub];
},
getUrl(location) {
return `https://gist.github.com/${location.gistId}`;
getLocationUrl({ gistId }) {
return `https://gist.github.com/${gistId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.filename}${location.gistId}${token.name}`;
getLocationDescription({ filename }) {
return filename;
},
async downloadContent(token, syncLocation) {
const content = await githubHelper.downloadGist({

View File

@ -2,21 +2,25 @@ import store from '../../store';
import githubHelper from './helpers/githubHelper';
import Provider from './common/Provider';
import utils from '../utils';
import fileSvc from '../fileSvc';
import workspaceSvc from '../workspaceSvc';
const savedSha = {};
export default new Provider({
id: 'github',
getToken(location) {
return store.getters['data/githubTokensBySub'][location.sub];
getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub];
},
getUrl(location) {
return `https://github.com/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}/blob/${encodeURIComponent(location.branch)}/${encodeURIComponent(location.path)}`;
getLocationUrl({
owner,
repo,
branch,
path,
}) {
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.path}${location.owner}/${location.repo}${token.name}`;
getLocationDescription({ path }) {
return path;
},
async downloadContent(token, syncLocation) {
try {
@ -59,7 +63,7 @@ export default new Provider({
},
async openFile(token, syncLocation) {
// Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], syncLocation)) {
if (!Provider.openFileWithLocation(syncLocation)) {
// Download content from GitHub
let content;
try {
@ -79,7 +83,7 @@ export default new Provider({
if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos);
}
const item = await fileSvc.createFile({
const item = await workspaceSvc.createFile({
name,
parentId: store.getters['file/current'].parentId,
text: content.text,
@ -88,21 +92,13 @@ export default new Provider({
comments: content.comments,
}, true);
store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', {
workspaceSvc.addSyncLocation({
...syncLocation,
id: utils.uid(),
fileId: item.id,
});
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`);
}
},
parseRepoUrl(url) {
const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && {
owner: parsedRepo[1],
repo: parsedRepo[2],
};
},
makeLocation(token, owner, repo, branch, path) {
return {
providerId: this.id,

View File

@ -4,18 +4,8 @@ import Provider from './common/Provider';
import utils from '../utils';
import userSvc from '../userSvc';
const getAbsolutePath = syncData =>
`${store.getters['workspace/currentWorkspace'].path || ''}${syncData.id}`;
const getWorkspaceWithOwner = () => {
const workspace = store.getters['workspace/currentWorkspace'];
const [owner, repo] = workspace.repo.split('/');
return {
...workspace,
owner,
repo,
};
};
const getAbsolutePath = ({ id }) =>
`${store.getters['workspace/currentWorkspace'].path || ''}${id}`;
let treeShaMap;
let treeFolderMap;
@ -31,14 +21,38 @@ export default new Provider({
getToken() {
return store.getters['workspace/syncToken'];
},
async initWorkspace() {
const [owner, repo] = (utils.queryParams.repo || '').split('/');
const { branch } = utils.queryParams;
const workspaceParams = {
providerId: this.id,
repo: `${owner}/${repo}`,
getWorkspaceParams({
owner,
repo,
branch,
path,
}) {
return {
providerId: this.id,
owner,
repo,
branch,
path,
};
},
getWorkspaceLocationUrl({
owner,
repo,
branch,
path,
}) {
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
},
getSyncDataUrl({ id }) {
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`;
},
getSyncDataDescription({ id }) {
return getAbsolutePath({ id });
},
async initWorkspace() {
const { owner, repo, branch } = utils.queryParams;
const workspaceParams = this.getWorkspaceParams({ owner, repo, branch });
const path = (utils.queryParams.path || '')
.replace(/^\/*/, '') // Remove leading `/`
.replace(/\/*$/, '/'); // Add trailing `/`
@ -46,7 +60,7 @@ export default new Provider({
workspaceParams.path = path;
}
const workspaceId = utils.makeWorkspaceId(workspaceParams);
let workspace = store.getters['data/sanitizedWorkspacesById'][workspaceId];
const workspace = store.getters['workspace/workspacesById'][workspaceId];
// See if we already have a token
let token;
@ -62,34 +76,23 @@ export default new Provider({
if (!workspace) {
const pathEntries = (path || '').split('/');
const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/`
workspace = {
store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: {
...workspaceParams,
id: workspaceId,
sub: token.sub,
name,
};
}
// Fix the URL hash
utils.setQueryParams(workspaceParams);
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspaceId]: {
...workspace,
url: window.location.href,
},
});
}
return store.getters['data/sanitizedWorkspacesById'][workspaceId];
return store.getters['workspace/workspacesById'][workspaceId];
},
getChanges() {
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.getTree({
...store.getters['workspace/currentWorkspace'],
token: syncToken,
owner,
repo,
branch,
});
},
prepareChanges(tree) {
@ -322,7 +325,7 @@ export default new Provider({
// locations are stored as paths, so we upload an empty file
const syncToken = store.getters['workspace/syncToken'];
await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token: syncToken,
path: getAbsolutePath(syncData),
content: '',
@ -334,7 +337,7 @@ export default new Provider({
if (treeShaMap[syncData.id]) {
const syncToken = store.getters['workspace/syncToken'];
await githubHelper.removeFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token: syncToken,
path: getAbsolutePath(syncData),
sha: treeShaMap[syncData.id],
@ -348,7 +351,7 @@ export default new Provider({
fileSyncData,
}) {
const { sha, data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(fileSyncData),
});
@ -369,7 +372,7 @@ export default new Provider({
}
const { sha, data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(syncData),
});
@ -388,7 +391,7 @@ export default new Provider({
const path = store.getters.gitPathsByItemId[file.id];
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token,
path: absolutePath,
content: Provider.serializeContent(content),
@ -418,7 +421,7 @@ export default new Provider({
hash: item.hash,
};
const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(syncData),
content: JSON.stringify(item),
@ -432,17 +435,8 @@ export default new Provider({
},
};
},
onSyncEnd() {
// Clean up
treeShaMap = null;
treeFolderMap = null;
treeFileMap = null;
treeDataMap = null;
treeSyncLocationMap = null;
treePublishLocationMap = null;
},
async listRevisions(token, fileId) {
const { owner, repo, branch } = getWorkspaceWithOwner();
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
const syncData = Provider.getContentSyncData(fileId);
const entries = await githubHelper.getCommits({
token,
@ -478,7 +472,7 @@ export default new Provider({
async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId);
const { data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(),
...store.getters['workspace/currentWorkspace'],
token,
branch: revisionId,
path: getAbsolutePath(syncData),

View File

@ -10,12 +10,25 @@ export default new Provider({
getToken() {
return store.getters['workspace/syncToken'];
},
getWorkspaceParams() {
// No param as it's the main workspace
return {};
},
getWorkspaceLocationUrl() {
// No direct link to app data
return null;
},
getSyncDataUrl() {
// No direct link to app data
return null;
},
getSyncDataDescription({ id }) {
return id;
},
async initWorkspace() {
// Nothing much to do since the main workspace isn't necessarily synchronized
// Remove the URL hash
utils.setQueryParams();
// Return the main workspace
return store.getters['data/workspacesById'].main;
return store.getters['workspace/workspacesById'].main;
},
async getChanges() {
const syncToken = store.getters['workspace/syncToken'];
@ -155,7 +168,7 @@ export default new Provider({
const revisions = await googleHelper.getAppDataFileRevisions(token, syncData.id);
return revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && `go:${revision.lastModifyingUser.permissionId}`,
sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(),
}))
.sort((revision1, revision2) => revision2.created - revision1.created);

View File

@ -2,20 +2,19 @@ import store from '../../store';
import googleHelper from './helpers/googleHelper';
import Provider from './common/Provider';
import utils from '../utils';
import fileSvc from '../fileSvc';
import workspaceSvc from '../workspaceSvc';
export default new Provider({
id: 'googleDrive',
getToken(location) {
const token = store.getters['data/googleTokensBySub'][location.sub];
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isDrive ? token : null;
},
getUrl(location) {
return `https://docs.google.com/file/d/${location.driveFileId}/edit`;
getLocationUrl({ driveFileId }) {
return `https://docs.google.com/file/d/${driveFileId}/edit`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.driveFileId}${token.name}`;
getLocationDescription({ driveFileId }) {
return driveFileId;
},
async initAction() {
const state = googleHelper.driveState || {};
@ -42,7 +41,7 @@ export default new Provider({
folderId,
};
const workspaceId = utils.makeWorkspaceId(workspaceParams);
const workspace = store.getters['data/sanitizedWorkspacesById'][workspaceId];
const workspace = store.getters['workspace/workspacesById'][workspaceId];
// If we have the workspace, open it by changing the current URL
if (workspace) {
utils.setQueryParams(workspaceParams);
@ -86,7 +85,7 @@ export default new Provider({
const token = store.getters['data/googleTokensBySub'][state.userId];
switch (token && state.action) {
case 'create': {
const file = await fileSvc.createFile({}, true);
const file = await workspaceSvc.createFile({}, true);
store.commit('file/setCurrentId', file.id);
// Return a new syncLocation
return this.makeLocation(token, null, googleHelper.driveActionFolder.id);
@ -142,7 +141,7 @@ export default new Provider({
async openFiles(token, driveFiles) {
return utils.awaitSequence(driveFiles, async (driveFile) => {
// Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], {
if (!Provider.openFileWithLocation({
providerId: this.id,
driveFileId: driveFile.id,
})) {
@ -161,7 +160,7 @@ export default new Provider({
}
// Create the file
const item = await fileSvc.createFile({
const item = await workspaceSvc.createFile({
name: driveFile.name,
parentId: store.getters['file/current'].parentId,
text: content.text,
@ -170,9 +169,8 @@ export default new Provider({
comments: content.comments,
}, true);
store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', {
workspaceSvc.addSyncLocation({
...syncLocation,
id: utils.uid(),
fileId: item.id,
});
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`);

View File

@ -2,7 +2,7 @@ import store from '../../store';
import googleHelper from './helpers/googleHelper';
import Provider from './common/Provider';
import utils from '../utils';
import fileSvc from '../fileSvc';
import workspaceSvc from '../workspaceSvc';
let fileIdToOpen;
let syncStartPageToken;
@ -12,17 +12,27 @@ export default new Provider({
getToken() {
return store.getters['workspace/syncToken'];
},
async initWorkspace() {
const makeWorkspaceParams = folderId => ({
getWorkspaceParams({ folderId }) {
return {
providerId: this.id,
folderId,
});
};
},
getWorkspaceLocationUrl({ folderId }) {
return `https://docs.google.com/folder/d/${folderId}`;
},
getSyncDataUrl({ id }) {
return `https://docs.google.com/file/d/${id}/edit`;
},
getSyncDataDescription({ id }) {
return id;
},
async initWorkspace() {
const makeWorkspaceId = folderId => folderId
&& utils.makeWorkspaceId(makeWorkspaceParams(folderId));
&& utils.makeWorkspaceId(this.getWorkspaceParams({ folderId }));
const getWorkspace = folderId =>
store.getters['data/sanitizedWorkspacesById'][makeWorkspaceId(folderId)];
store.getters['workspace/workspacesById'][makeWorkspaceId(folderId)];
const initFolder = async (token, folder) => {
const appProperties = {
@ -33,24 +43,26 @@ export default new Provider({
// Make sure data folder exists
if (!appProperties.dataFolderId) {
appProperties.dataFolderId = (await googleHelper.uploadFile({
const dataFolder = await googleHelper.uploadFile({
token,
name: '.stackedit-data',
parents: [folder.id],
appProperties: { folderId: folder.id },
mediaType: googleHelper.folderMimeType,
})).id;
});
appProperties.dataFolderId = dataFolder.id;
}
// Make sure trash folder exists
if (!appProperties.trashFolderId) {
appProperties.trashFolderId = (await googleHelper.uploadFile({
const trashFolder = await googleHelper.uploadFile({
token,
name: '.stackedit-trash',
parents: [folder.id],
appProperties: { folderId: folder.id },
mediaType: googleHelper.folderMimeType,
})).id;
});
appProperties.trashFolderId = trashFolder.id;
}
// Update workspace if some properties are missing
@ -68,13 +80,12 @@ export default new Provider({
// Update workspace in the store
const workspaceId = makeWorkspaceId(folder.id);
store.dispatch('data/patchWorkspacesById', {
store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: {
id: workspaceId,
sub: token.sub,
name: folder.name,
providerId: this.id,
url: window.location.href,
folderId: folder.id,
teamDriveId: folder.teamDriveId,
dataFolderId: appProperties.dataFolderId,
@ -110,11 +121,10 @@ export default new Provider({
}
// Init workspace
let workspace = getWorkspace(folderId);
if (!workspace) {
if (!getWorkspace(folderId)) {
let folder;
try {
folder = googleHelper.getFile(token, folderId);
folder = await googleHelper.getFile(token, folderId);
} catch (err) {
throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`);
}
@ -124,20 +134,9 @@ export default new Provider({
throw new Error(`Folder ${folderId} is part of another workspace.`);
}
await initFolder(token, folder);
workspace = getWorkspace(folderId);
}
// Fix the URL hash
utils.setQueryParams(makeWorkspaceParams(workspace.folderId));
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspace.id]: {
...workspace,
url: window.location.href,
},
});
}
return store.getters['data/sanitizedWorkspacesById'][workspace.id];
return getWorkspace(folderId);
},
async performAction() {
const state = googleHelper.driveState || {};
@ -163,7 +162,7 @@ export default new Provider({
[syncData.id]: syncData,
});
}
const file = await fileSvc.createFile({
const file = await workspaceSvc.createFile({
parentId: syncData && syncData.itemId,
}, true);
store.commit('file/setCurrentId', file.id);
@ -486,6 +485,7 @@ export default new Provider({
folderId: workspace.folderId,
},
media: JSON.stringify(item),
mediaType: 'application/json',
fileId: syncData && syncData.id,
oldParents: syncData && syncData.parentIds,
ifNotTooLate,

View File

@ -8,13 +8,16 @@ const getAppKey = (fullAccess) => {
return 'sw0hlixhr8q1xk0';
};
const httpHeaderSafeJson = args => args && JSON.stringify(args)
.replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`);
const request = (token, options, args) => networkSvc.request({
...options,
headers: {
...options.headers || {},
'Content-Type': options.body && (typeof options.body === 'string'
? 'application/octet-stream' : 'application/json; charset=utf-8'),
'Dropbox-API-Arg': args && JSON.stringify(args),
'Dropbox-API-Arg': httpHeaderSafeJson(args),
Authorization: `Bearer ${token.accessToken}`,
},
});

View File

@ -413,7 +413,7 @@ export default {
});
revisions.forEach((revision) => {
store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId,
id: `go:${revision.lastModifyingUser.permissionId}`,
name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink,
});

View File

@ -7,12 +7,11 @@ export default new Provider({
getToken(location) {
return store.getters['data/wordpressTokensBySub'][location.sub];
},
getUrl(location) {
getLocationUrl(location) {
return `https://wordpress.com/post/${location.siteId}/${location.postId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.postId}${location.domain}${token.name}`;
getLocationDescription({ postId }) {
return postId;
},
async publish(token, html, metadata, publishLocation) {
const post = await wordpressHelper.uploadPost({

View File

@ -7,13 +7,12 @@ export default new Provider({
getToken(location) {
return store.getters['data/zendeskTokensBySub'][location.sub];
},
getUrl(location) {
getLocationUrl(location) {
const token = this.getToken(location);
return `https://${token.subdomain}.zendesk.com/hc/${location.locale}/articles/${location.articleId}`;
},
getDescription(location) {
const token = this.getToken(location);
return `${location.articleId}${token.name}${token.subdomain}`;
getLocationDescription({ articleId }) {
return articleId;
},
async publish(token, html, metadata, publishLocation) {
const articleId = await zendeskHelper.uploadArticle({

View File

@ -4,6 +4,7 @@ import utils from './utils';
import networkSvc from './networkSvc';
import exportSvc from './exportSvc';
import providerRegistry from './providers/common/providerRegistry';
import workspaceSvc from './workspaceSvc';
const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;
@ -45,7 +46,7 @@ const publish = async (publishLocation) => {
const content = await localDbSvc.loadItem(`${fileId}/content`);
const file = store.state.file.itemsById[fileId];
const properties = utils.computeProperties(content.properties);
const provider = providerRegistry.providers[publishLocation.providerId];
const provider = providerRegistry.providersById[publishLocation.providerId];
const token = provider.getToken(publishLocation);
const metadata = {
title: ensureString(properties.title, file.name),
@ -122,14 +123,12 @@ const requestPublish = () => {
};
const createPublishLocation = (publishLocation) => {
publishLocation.id = utils.uid();
const currentFile = store.getters['file/current'];
publishLocation.fileId = currentFile.id;
store.dispatch(
'queue/enqueue',
async () => {
const publishLocationToStore = await publish(publishLocation);
store.commit('publishLocation/setItem', publishLocationToStore);
workspaceSvc.addPublishLocation(await publish(publishLocation));
store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`);
},
);

View File

@ -1,113 +0,0 @@
class SectionDimension {
constructor(startOffset, endOffset) {
this.startOffset = startOffset;
this.endOffset = endOffset;
this.height = endOffset - startOffset;
}
}
const dimensionNormalizer = dimensionName => (editorSvc) => {
const dimensionList = editorSvc.previewCtx.sectionDescList
.map(sectionDesc => sectionDesc[dimensionName]);
let dimension;
let i;
let j;
for (i = 0; i < dimensionList.length; i += 1) {
dimension = dimensionList[i];
if (dimension.height) {
for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) {
// Loop
}
const normalizeFactor = j - i;
if (normalizeFactor !== 1) {
const normalizedHeight = dimension.height / normalizeFactor;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
for (j = i + 1; j < i + normalizeFactor; j += 1) {
const startOffset = dimension.endOffset;
dimension = dimensionList[j];
dimension.startOffset = startOffset;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
}
i = j - 1;
}
}
}
};
const normalizeEditorDimensions = dimensionNormalizer('editorDimension');
const normalizePreviewDimensions = dimensionNormalizer('previewDimension');
const normalizeTocDimensions = dimensionNormalizer('tocDimension');
const measureSectionDimensions = (editorSvc) => {
let editorSectionOffset = 0;
let previewSectionOffset = 0;
let tocSectionOffset = 0;
let sectionDesc = editorSvc.previewCtx.sectionDescList[0];
let nextSectionDesc;
let i = 1;
for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) {
nextSectionDesc = editorSvc.previewCtx.sectionDescList[i];
// Measure editor section
let newEditorSectionOffset = nextSectionDesc.editorElt
? nextSectionDesc.editorElt.offsetTop
: editorSectionOffset;
newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset
? newEditorSectionOffset
: editorSectionOffset;
sectionDesc.editorDimension = new SectionDimension(editorSectionOffset, newEditorSectionOffset);
editorSectionOffset = newEditorSectionOffset;
// Measure preview section
let newPreviewSectionOffset = nextSectionDesc.previewElt
? nextSectionDesc.previewElt.offsetTop
: previewSectionOffset;
newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset
? newPreviewSectionOffset
: previewSectionOffset;
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
newPreviewSectionOffset,
);
previewSectionOffset = newPreviewSectionOffset;
// Measure TOC section
let newTocSectionOffset = nextSectionDesc.tocElt
? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2)
: tocSectionOffset;
newTocSectionOffset = newTocSectionOffset > tocSectionOffset
? newTocSectionOffset
: tocSectionOffset;
sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset);
tocSectionOffset = newTocSectionOffset;
sectionDesc = nextSectionDesc;
}
// Last section
sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1];
if (sectionDesc) {
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
editorSvc.editorElt.scrollHeight,
);
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
editorSvc.previewElt.scrollHeight,
);
sectionDesc.tocDimension = new SectionDimension(
tocSectionOffset,
editorSvc.tocElt.scrollHeight,
);
}
normalizeEditorDimensions(editorSvc);
normalizePreviewDimensions(editorSvc);
normalizeTocDimensions(editorSvc);
};
export default {
measureSectionDimensions,
};

View File

@ -9,12 +9,12 @@ import './providers/couchdbWorkspaceProvider';
import './providers/githubWorkspaceProvider';
import './providers/googleDriveWorkspaceProvider';
import tempFileSvc from './tempFileSvc';
import fileSvc from './fileSvc';
import workspaceSvc from './workspaceSvc';
const minAutoSyncEvery = 60 * 1000; // 60 sec
const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec
const restartContentSyncAfter = 500; // Restart if an authorize window pops up
const restartContentSyncAfter = 1000; // Enough to detect an authorize pop up
const maxContentHistory = 20;
const LAST_SEEN = 0;
@ -112,9 +112,11 @@ const cleanSyncedContent = (syncedContent) => {
delete syncedContent.syncHistory[syncLocationId];
}
});
const allSyncLocationHashSet = new Set([]
.concat(...Object.keys(syncedContent.syncHistory)
.map(id => syncedContent.syncHistory[id])));
// Clean historyData from unused contents
Object.keys(syncedContent.historyData)
.map(hash => parseInt(hash, 10))
@ -131,9 +133,11 @@ const cleanSyncedContent = (syncedContent) => {
const applyChanges = (changes) => {
const allItemsById = { ...store.getters.allItemsById };
const syncDataById = { ...store.getters['data/syncDataById'] };
const idsToKeep = {};
let saveSyncData = false;
let getExistingItem;
if (workspaceProvider.isGit) {
const { itemsByGitPath } = store.getters;
if (store.getters['workspace/currentWorkspaceIsGit']) {
const itemsByGitPath = { ...store.getters.itemsByGitPath };
getExistingItem = (existingSyncData) => {
const items = existingSyncData && itemsByGitPath[existingSyncData.id];
return items ? items[0] : null;
@ -142,9 +146,6 @@ const applyChanges = (changes) => {
getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId];
}
const idsToKeep = {};
let saveSyncData = false;
// Process each change
changes.forEach((change) => {
const existingSyncData = syncDataById[change.syncDataId];
@ -184,7 +185,10 @@ const applyChanges = (changes) => {
if (saveSyncData) {
store.dispatch('data/setSyncDataById', syncDataById);
fileSvc.ensureUniquePaths(idsToKeep);
// Sanitize the workspace
workspaceSvc.ensureUniquePaths(idsToKeep);
workspaceSvc.ensureUniqueLocations(idsToKeep);
}
};
@ -192,18 +196,17 @@ const applyChanges = (changes) => {
* Create a sync location by uploading the current file content.
*/
const createSyncLocation = (syncLocation) => {
syncLocation.id = utils.uid();
const currentFile = store.getters['file/current'];
const fileId = currentFile.id;
syncLocation.fileId = fileId;
// Use deepCopy to freeze item
// Use deepCopy to freeze the item
const content = utils.deepCopy(store.getters['content/current']);
store.dispatch(
'queue/enqueue',
async () => {
const provider = providerRegistry.providers[syncLocation.providerId];
const provider = providerRegistry.providersById[syncLocation.providerId];
const token = provider.getToken(syncLocation);
const syncLocationToStore = await provider.uploadContent(token, {
const updatedSyncLocation = await provider.uploadContent(token, {
...content,
history: [content.hash],
}, syncLocation);
@ -216,7 +219,7 @@ const createSyncLocation = (syncLocation) => {
newSyncedContent.historyData[content.hash] = content;
store.commit('syncedContent/patchItem', newSyncedContent);
store.commit('syncLocation/setItem', syncLocationToStore);
workspaceSvc.addSyncLocation(updatedSyncLocation);
store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`);
},
);
@ -241,7 +244,7 @@ const tooLateChecker = (timeout) => {
const isTempFile = (fileId) => {
const contentId = `${fileId}/content`;
if (store.getters['data/syncDataByItemId'][contentId]) {
// If file has already been synced, it's not a temp file
// If file has already been synced, let's not consider it a temp file
return false;
}
const file = store.state.file.itemsById[fileId];
@ -271,8 +274,11 @@ const isTempFile = (fileId) => {
* Patch sync data if some have changed in the result.
*/
const updateSyncData = (result) => {
['syncData', 'contentSyncData', 'fileSyncData'].forEach((field) => {
const syncData = result[field];
[
result.syncData,
result.contentSyncData,
result.fileSyncData,
].forEach((syncData) => {
if (syncData) {
const oldSyncData = store.getters['data/syncDataById'][syncData.id];
if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) {
@ -286,7 +292,7 @@ const updateSyncData = (result) => {
};
class SyncContext {
restart = false;
restartSkipContents = false;
attempted = {};
}
@ -320,7 +326,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
}
await utils.awaitSequence(syncLocations, async (syncLocation) => {
const provider = providerRegistry.providers[syncLocation.providerId];
const provider = providerRegistry.providersById[syncLocation.providerId];
if (!provider) {
return;
}
@ -491,7 +497,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (provider === workspaceProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
syncContext.restart = true;
syncContext.restartSkipContents = true;
}
};
@ -514,15 +520,16 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
await syncFile(fileId, syncContext);
}
} else {
throw err;
}
} finally {
await localDbSvc.unloadContents();
}
};
/**
* Sync a data item, typically settings, workspaces and templates.
* Sync a data item, typically settings, templates or workspaces.
*/
const syncDataItem = async (dataId) => {
const getItem = () => store.state.data.itemsById[dataId]
@ -543,8 +550,8 @@ const syncDataItem = async (dataId) => {
const serverItem = item;
const dataSyncData = store.getters['data/dataSyncDataById'][dataId];
let mergedItem = (() => {
const clientItem = utils.deepCopy(getItem());
let mergedItem = (() => {
if (!clientItem) {
return serverItem;
}
@ -572,6 +579,13 @@ const syncDataItem = async (dataId) => {
return;
}
if (clientItem && dataId === 'workspaces') {
// Clean deleted workspaces
await Promise.all(Object.keys(clientItem.data)
.filter(id => !mergedItem.data[id])
.map(id => workspaceSvc.removeWorkspace(id)));
}
// Update item in store
store.commit('data/setItem', {
id: dataId,
@ -581,19 +595,17 @@ const syncDataItem = async (dataId) => {
// Retrieve item with new `hash` and freeze it
mergedItem = utils.deepCopy(getItem());
if (serverItem && serverItem.hash === mergedItem.hash) {
return;
}
// Upload merged data item
// Upload merged data item if out of sync
if (!serverItem || serverItem.hash !== mergedItem.hash) {
updateSyncData(await workspaceProvider.uploadWorkspaceData({
token,
item: mergedItem,
syncData: store.getters['data/syncDataByItemId'][dataId],
ifNotTooLate: tooLateChecker(restartContentSyncAfter),
}));
}
// Update data sync data
// Copy sync data into data sync data
store.dispatch('data/patchDataSyncDataById', {
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
});
@ -602,7 +614,7 @@ const syncDataItem = async (dataId) => {
/**
* Sync the whole workspace with the main provider and the current file explicit locations.
*/
const syncWorkspace = async () => {
const syncWorkspace = async (skipContents = false) => {
try {
const workspace = store.getters['workspace/currentWorkspace'];
const syncContext = new SyncContext();
@ -627,8 +639,8 @@ const syncWorkspace = async () => {
// Prevent from sending items too long after changes have been retrieved
const ifNotTooLate = tooLateChecker(restartSyncAfter);
// Called until no item to save
const saveNextItem = () => ifNotTooLate(async () => {
// Find and save one item to save
await utils.awaitSome(() => ifNotTooLate(async () => {
const storeItemMap = {
...store.state.file.itemsById,
...store.state.folder.itemsById,
@ -654,7 +666,8 @@ const syncWorkspace = async () => {
},
) || [];
if (changedItem) {
if (!changedItem) return false;
const resultSyncData = await workspaceProvider
.saveWorkspaceItem({
// Use deepCopy to freeze objects
@ -665,16 +678,14 @@ const syncWorkspace = async () => {
store.dispatch('data/patchSyncDataById', {
[resultSyncData.id]: resultSyncData,
});
await saveNextItem();
}
});
await saveNextItem();
return true;
}));
// Called until no item to remove
const removeNextItem = () => ifNotTooLate(async () => {
// Find and remove one item to remove
await utils.awaitSome(() => ifNotTooLate(async () => {
let getItem;
let getFileItem;
if (workspaceProvider.isGit) {
if (store.getters['workspace/currentWorkspaceIsGit']) {
const { itemsByGitPath } = store.getters;
getItem = syncData => itemsByGitPath[syncData.id];
getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading /
@ -701,7 +712,8 @@ const syncWorkspace = async () => {
},
));
if (syncDataToRemove) {
if (!syncDataToRemove) return false;
await workspaceProvider.removeWorkspaceItem({
syncData: syncDataToRemove,
ifNotTooLate,
@ -709,10 +721,8 @@ const syncWorkspace = async () => {
const syncDataCopy = { ...store.getters['data/syncDataById'] };
delete syncDataCopy[syncDataToRemove.id];
store.dispatch('data/setSyncDataById', syncDataCopy);
await removeNextItem();
}
});
await removeNextItem();
return true;
}));
// Sync settings and workspaces only in the main workspace
if (workspace.id === 'main') {
@ -721,16 +731,23 @@ const syncWorkspace = async () => {
}
await syncDataItem('templates');
const getOneFileIdToSync = () => {
if (!skipContents) {
const currentFileId = store.getters['file/current'].id;
if (currentFileId) {
// Sync current file first
await syncFile(currentFileId, syncContext);
}
// Find and sync one file out of sync
await utils.awaitSome(async () => {
let getSyncData;
if (workspaceProvider.isGit) {
if (store.getters['workspace/currentWorkspaceIsGit']) {
const { gitPathsByItemId } = store.getters;
const syncDataById = store.getters['data/syncDataById'];
// Use file git path as content may not exist or not be loaded
getSyncData = fileId => syncDataById[`/${gitPathsByItemId[fileId]}`];
getSyncData = contentId => syncDataById[gitPathsByItemId[contentId]];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = (fileId, contentId) => syncDataByItemId[contentId];
getSyncData = contentId => syncDataByItemId[contentId];
}
// Collect all [fileId, contentId]
@ -743,11 +760,11 @@ const syncWorkspace = async () => {
// Find the first content out of sync
const contentMap = store.state.content.itemsById;
return utils.someResult(ids, ([fileId, contentId]) => {
// Get content hash from itemsById or from localDbSvc if not loaded
const fileIdToSync = utils.someResult(ids, ([fileId, contentId]) => {
// Get the content hash from itemsById or from localDbSvc if not loaded
const loadedContent = contentMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = getSyncData(fileId, contentId);
const syncData = getSyncData(contentId);
if (
// Sync if content syncing was not attempted yet
!syncContext.attempted[contentId] &&
@ -758,26 +775,17 @@ const syncWorkspace = async () => {
}
return null;
});
};
const syncNextFile = async () => {
const fileId = getOneFileIdToSync();
if (fileId) {
await syncFile(fileId, syncContext);
await syncNextFile();
if (!fileIdToSync) return false;
await syncFile(fileIdToSync, syncContext);
return true;
});
}
};
const currentFileId = store.getters['file/current'].id;
if (currentFileId) {
// Sync current file first
await syncFile(currentFileId, syncContext);
}
await syncNextFile();
if (syncContext.restart) {
// Restart sync
await syncWorkspace();
// Restart sync if requested
if (syncContext.restartSkipContents) {
await syncWorkspace(true);
}
} catch (err) {
if (err && err.message === 'TOO_LATE') {
@ -786,10 +794,6 @@ const syncWorkspace = async () => {
} else {
throw err;
}
} finally {
if (workspaceProvider.onSyncEnd) {
workspaceProvider.onSyncEnd();
}
}
};
@ -835,9 +839,9 @@ const requestSync = () => {
if (isWorkspaceSyncPossible()) {
await syncWorkspace();
} else if (hasCurrentFileSyncLocations()) {
// Only sync current file if workspace sync is unavailable.
// We could sync all files that are out-of-sync but it would
// require to load all the syncedContent objects from the DB.
// Only sync the current file if workspace sync is unavailable
// as we don't want to look for out-of-sync files by loading
// all the syncedContent objects.
await syncFile(store.getters['file/current'].id);
}
@ -845,7 +849,7 @@ const requestSync = () => {
Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => {
const file = store.state.file.itemsById[fileId];
if (file && file.hash === fileHash) {
fileSvc.deleteFile(fileId);
workspaceSvc.deleteFile(fileId);
}
});
} finally {
@ -865,22 +869,25 @@ export default {
localDbSvc.syncLocalStorage();
// Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId];
actionProvider = providerRegistry.providersById[utils.queryParams.providerId];
if (actionProvider && actionProvider.initAction) {
await actionProvider.initAction();
}
// Try to find a suitable workspace sync provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId];
workspaceProvider = providerRegistry.providersById[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider;
}
const workspace = await workspaceProvider.initWorkspace();
// Fix the URL hash
utils.setQueryParams(workspaceProvider.getWorkspaceParams(workspace));
store.dispatch('workspace/setCurrentWorkspaceId', workspace.id);
await localDbSvc.init();
// Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId] || actionProvider;
actionProvider = providerRegistry.providersById[utils.queryParams.providerId] || actionProvider;
if (actionProvider && actionProvider.performAction) {
const newSyncLocation = await actionProvider.performAction();
if (newSyncLocation) {

View File

@ -1,8 +1,8 @@
import cledit from './cledit';
import cledit from './editor/cledit';
import store from '../store';
import utils from './utils';
import editorSvc from './editorSvc';
import fileSvc from './fileSvc';
import workspaceSvc from './workspaceSvc';
const {
origin,
@ -31,7 +31,7 @@ export default {
}
store.commit('setLight', true);
const file = await fileSvc.createFile({
const file = await workspaceSvc.createFile({
name: fileName || utils.getHostname(origin),
text: contentText || '\n',
properties: contentProperties,
@ -58,7 +58,7 @@ export default {
.splice(10)
.forEach(([id]) => {
delete lastCreated[id];
fileSvc.deleteFile(id);
workspaceSvc.deleteFile(id);
});
// Store file creations and open the file

View File

@ -170,6 +170,13 @@ export default {
makeWorkspaceId(params) {
return Math.abs(this.hash(this.serializeObject(params))).toString(36);
},
getDbName(workspaceId) {
let dbName = 'stackedit-db';
if (workspaceId !== 'main') {
dbName += `-${workspaceId}`;
}
return dbName;
},
encodeBase64(str, urlSafe = false) {
const uriEncodedStr = encodeURIComponent(str);
const utf8Str = uriEncodedStr.replace(
@ -227,6 +234,12 @@ export default {
};
return runWithNextValue();
},
async awaitSome(asyncFunc) {
if (await asyncFunc()) {
return this.awaitSome(asyncFunc);
}
return null;
},
someResult(values, func) {
let result;
values.some((value) => {
@ -279,6 +292,16 @@ export default {
urlParser.href = url;
return urlParser.hostname;
},
encodeUrlPath(path) {
return path ? path.split('/').map(encodeURIComponent).join('/') : '';
},
parseGithubRepoUrl(url) {
const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && {
owner: parsedRepo[1],
repo: parsedRepo[2],
};
},
createHiddenIframe(url) {
const iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute';

View File

@ -4,6 +4,7 @@ import utils from './utils';
const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/;
export default {
/**
* Create a file in the store with the specified fields.
*/
@ -29,7 +30,7 @@ export default {
discussions: discussions || {},
comments: comments || {},
};
const workspaceUniquePaths = store.getters['workspace/hasUniquePaths'];
const workspaceUniquePaths = store.getters['workspace/currentWorkspaceHasUniquePaths'];
// Show warning dialogs
if (!background) {
@ -90,7 +91,7 @@ export default {
}
// Check if there is a path conflict
if (store.getters['workspace/hasUniquePaths']) {
if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
const parentPath = store.getters.pathsByItemId[item.parentId] || '';
const path = parentPath + sanitizedName;
const items = store.getters.itemsByPath[path] || [];
@ -133,7 +134,7 @@ export default {
store.commit(`${item.type}/setItem`, item);
// Ensure path uniqueness
if (store.getters['workspace/hasUniquePaths']) {
if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
this.makePathUnique(item.id);
}
@ -164,7 +165,7 @@ export default {
* Ensure two files/folders don't have the same path if the workspace doesn't allow it.
*/
ensureUniquePaths(idsToKeep = {}) {
if (store.getters['workspace/hasUniquePaths']) {
if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
if (Object.keys(store.getters.pathsByItemId)
.some(id => !idsToKeep[id] && this.makePathUnique(id))
) {
@ -207,4 +208,59 @@ export default {
}
}
},
addSyncLocation(location) {
store.commit('syncLocation/setItem', {
...location,
id: utils.uid(),
});
// Sanitize the workspace
this.ensureUniqueLocations();
},
addPublishLocation(location) {
store.commit('publishLocation/setItem', {
...location,
id: utils.uid(),
});
// Sanitize the workspace
this.ensureUniqueLocations();
},
/**
* Ensure two sync/publish locations of the same file don't have the same hash.
*/
ensureUniqueLocations(idsToKeep = {}) {
['syncLocation', 'publishLocation'].forEach((type) => {
store.getters[`${type}/items`].forEach((item) => {
if (!idsToKeep[item.id]
&& store.getters[`${type}/groupedByFileIdAndHash`][item.fileId][item.hash].length > 1
) {
store.commit(`${item.type}/deleteItem`, item.id);
}
});
});
},
/**
* Drop the database and clean the localStorage for the specified workspaceId.
*/
async removeWorkspace(id) {
// Remove from the store first as workspace tabs will reload.
// Workspace deletion will be persisted as soon as possible
// by the store.getters['data/workspaces'] watcher in localDbSvc.
store.dispatch('workspace/removeWorkspace', id);
// Drop the database
await new Promise((resolve) => {
const dbName = utils.getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = resolve; // Ignore errors
request.onsuccess = resolve;
});
// Clean the local storage
localStorage.removeItem(`${id}/lastSyncActivity`);
localStorage.removeItem(`${id}/lastWindowFocus`);
},
};

View File

@ -2,7 +2,7 @@ import DiffMatchPatch from 'diff-match-patch';
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyContent';
import utils from '../services/utils';
import cledit from '../services/cledit';
import cledit from '../services/editor/cledit';
const diffMatchPatch = new DiffMatchPatch();

View File

@ -92,9 +92,6 @@ const tokenAdder = providerId => ({ getters, dispatch }, token) => {
});
};
// For workspaces
const urlParser = window.document.createElement('a');
export default {
namespaced: true,
state: {
@ -126,25 +123,7 @@ export default {
},
},
getters: {
workspacesById: getter('workspaces'),
sanitizedWorkspacesById: (state, { workspacesById }, rootState, rootGetters) => {
const sanitizedWorkspacesById = {};
const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];
Object.entries(workspacesById).forEach(([id, workspace]) => {
const sanitizedWorkspace = {
id,
providerId: mainWorkspaceToken && 'googleDriveAppData',
sub: mainWorkspaceToken && mainWorkspaceToken.sub,
...workspace,
};
// Rebuild the url with current hostname
urlParser.href = workspace.url || 'app';
const params = utils.parseQueryParams(urlParser.hash.slice(1));
sanitizedWorkspace.url = utils.addQueryParams('app', params, true);
sanitizedWorkspacesById[id] = sanitizedWorkspace;
});
return sanitizedWorkspacesById;
},
workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById
settings: getter('settings'),
computedSettings: (state, { settings }) => {
const customSettings = yaml.safeLoad(settings);
@ -207,18 +186,6 @@ export default {
}
return result;
},
syncDataByType: (state, { syncDataById }) => {
const result = {};
utils.types.forEach((type) => {
result[type] = {};
});
Object.entries(syncDataById).forEach(([, item]) => {
if (result[item.type]) {
result[item.type][item.itemId] = item;
}
});
return result;
},
dataSyncDataById: getter('dataSyncData'),
tokensByProviderId: getter('tokens'),
googleTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.google || {},
@ -229,8 +196,6 @@ export default {
zendeskTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.zendesk || {},
},
actions: {
setWorkspacesById: setter('workspaces'),
patchWorkspacesById: patcher('workspaces'),
setSettings: setter('settings'),
patchLocalSettings: patcher('localSettings'),
patchLayoutSettings: patcher('layoutSettings'),

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