Workspaces (part 1)

This commit is contained in:
benweet 2017-12-10 23:49:20 +00:00
parent 3a08bc617e
commit 9596339684
53 changed files with 1406 additions and 621 deletions

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="app">
<splash-screen v-if="!ready"></splash-screen> <splash-screen v-if="!ready"></splash-screen>
<div v-else class="app"> <layout v-else></layout>
<layout></layout>
<modal v-if="showModal"></modal> <modal v-if="showModal"></modal>
<notification></notification> <notification></notification>
</div> </div>
@ -9,11 +9,11 @@
<script> <script>
import Vue from 'vue'; import Vue from 'vue';
import { mapState } from 'vuex';
import Layout from './Layout'; import Layout from './Layout';
import Modal from './Modal'; import Modal from './Modal';
import Notification from './Notification'; import Notification from './Notification';
import SplashScreen from './SplashScreen'; import SplashScreen from './SplashScreen';
import syncSvc from '../services/syncSvc';
import timeSvc from '../services/timeSvc'; import timeSvc from '../services/timeSvc';
import store from '../store'; import store from '../store';
@ -66,14 +66,20 @@ export default {
Notification, Notification,
SplashScreen, SplashScreen,
}, },
data: () => ({
ready: false,
}),
computed: { computed: {
...mapState([
'ready',
]),
showModal() { showModal() {
return !!this.$store.getters['modal/config']; return !!this.$store.getters['modal/config'];
}, },
}, },
created() {
syncSvc.init()
.then(() => {
this.ready = true;
});
},
}; };
</script> </script>

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="button-bar"> <div class="button-bar">
<div class="button-bar__inner button-bar__inner--top"> <div class="button-bar__inner button-bar__inner--top">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showNavigationBar }" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'">
<icon-navigation-bar></icon-navigation-bar> <icon-navigation-bar></icon-navigation-bar>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showSidePreview }" @click="toggleSidePreview()" v-title="'Toggle side preview'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" @click="toggleSidePreview()" v-title="'Toggle side preview'">
<icon-side-preview></icon-side-preview> <icon-side-preview></icon-side-preview>
</button> </button>
<button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'"> <button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'">
@ -12,13 +12,13 @@
</button> </button>
</div> </div>
<div class="button-bar__inner button-bar__inner--bottom"> <div class="button-bar__inner button-bar__inner--bottom">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'">
<icon-target></icon-target> <icon-target></icon-target>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
<icon-scroll-sync></icon-scroll-sync> <icon-scroll-sync></icon-scroll-sync>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': localSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'"> <button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
<icon-status-bar></icon-status-bar> <icon-status-bar></icon-status-bar>
</button> </button>
</div> </div>
@ -30,7 +30,7 @@ import { mapGetters, mapActions } from 'vuex';
export default { export default {
computed: mapGetters('data', [ computed: mapGetters('data', [
'localSettings', 'layoutSettings',
]), ]),
methods: mapActions('data', [ methods: mapActions('data', [
'toggleNavigationBar', 'toggleNavigationBar',

View File

@ -84,7 +84,10 @@ export default {
if (node.isFolder) { if (node.isFolder) {
this.$store.commit('explorer/toggleOpenNode', id); this.$store.commit('explorer/toggleOpenNode', id);
} else { } else {
// Prevent from freezing the UI while loading the file
setTimeout(() => {
this.$store.commit('file/setCurrentId', id); this.$store.commit('file/setCurrentId', id);
}, 10);
} }
} }
}, },

View File

@ -47,12 +47,12 @@ const accessor = (fieldName, setterName) => ({
}, },
}); });
const computedLocalSetting = key => ({ const computedLayoutSetting = key => ({
get() { get() {
return store.getters['data/localSettings'][key]; return store.getters['data/layoutSettings'][key];
}, },
set(value) { set(value) {
store.dispatch('data/patchLocalSettings', { store.dispatch('data/patchLayoutSettings', {
[key]: value, [key]: value,
}); });
}, },
@ -95,14 +95,13 @@ export default {
]), ]),
findText: accessor('findText', 'setFindText'), findText: accessor('findText', 'setFindText'),
replaceText: accessor('replaceText', 'setReplaceText'), replaceText: accessor('replaceText', 'setReplaceText'),
findCaseSensitive: computedLocalSetting('findCaseSensitive'), findCaseSensitive: computedLayoutSetting('findCaseSensitive'),
findUseRegexp: computedLocalSetting('findUseRegexp'), findUseRegexp: computedLayoutSetting('findUseRegexp'),
}, },
methods: { methods: {
highlightOccurrences() { highlightOccurrences() {
const oldClassAppliers = {}; const oldClassAppliers = {};
Object.keys(this.classAppliers).forEach((key) => { Object.entries(this.classAppliers).forEach(([, classApplier]) => {
const classApplier = this.classAppliers[key];
const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`; const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;
oldClassAppliers[newKey] = classApplier; oldClassAppliers[newKey] = classApplier;
}); });
@ -137,8 +136,7 @@ export default {
this.state = 'created'; this.state = 'created';
} }
} }
Object.keys(oldClassAppliers).forEach((key) => { Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {
const classApplier = oldClassAppliers[key];
if (!this.classAppliers[key]) { if (!this.classAppliers[key]) {
classApplier.clean(); classApplier.clean();
if (classApplier === this.selectedClassApplier) { if (classApplier === this.selectedClassApplier) {

View File

@ -11,10 +11,12 @@
<image-modal v-else-if="config.type === 'image'"></image-modal> <image-modal v-else-if="config.type === 'image'"></image-modal>
<sync-management-modal v-else-if="config.type === 'syncManagement'"></sync-management-modal> <sync-management-modal v-else-if="config.type === 'syncManagement'"></sync-management-modal>
<publish-management-modal v-else-if="config.type === 'publishManagement'"></publish-management-modal> <publish-management-modal v-else-if="config.type === 'publishManagement'"></publish-management-modal>
<workspace-management-modal v-else-if="config.type === 'workspaceManagement'"></workspace-management-modal>
<sponsor-modal v-else-if="config.type === 'sponsor'"></sponsor-modal> <sponsor-modal v-else-if="config.type === 'sponsor'"></sponsor-modal>
<!-- Providers --> <!-- Providers -->
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal> <google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
<google-drive-save-modal v-else-if="config.type === 'googleDriveSave'"></google-drive-save-modal> <google-drive-save-modal v-else-if="config.type === 'googleDriveSave'"></google-drive-save-modal>
<google-drive-workspace-modal v-else-if="config.type === 'googleDriveWorkspace'"></google-drive-workspace-modal>
<google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal> <google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal>
<dropbox-account-modal v-else-if="config.type === 'dropboxAccount'"></dropbox-account-modal> <dropbox-account-modal v-else-if="config.type === 'dropboxAccount'"></dropbox-account-modal>
<dropbox-save-modal v-else-if="config.type === 'dropboxSave'"></dropbox-save-modal> <dropbox-save-modal v-else-if="config.type === 'dropboxSave'"></dropbox-save-modal>
@ -55,11 +57,13 @@ import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal'; import ImageModal from './modals/ImageModal';
import SyncManagementModal from './modals/SyncManagementModal'; import SyncManagementModal from './modals/SyncManagementModal';
import PublishManagementModal from './modals/PublishManagementModal'; import PublishManagementModal from './modals/PublishManagementModal';
import WorkspaceManagementModal from './modals/WorkspaceManagementModal';
import SponsorModal from './modals/SponsorModal'; import SponsorModal from './modals/SponsorModal';
// Providers // Providers
import GooglePhotoModal from './modals/providers/GooglePhotoModal'; import GooglePhotoModal from './modals/providers/GooglePhotoModal';
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal'; import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
import GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal'; import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
import DropboxAccountModal from './modals/providers/DropboxAccountModal'; import DropboxAccountModal from './modals/providers/DropboxAccountModal';
import DropboxSaveModal from './modals/providers/DropboxSaveModal'; import DropboxSaveModal from './modals/providers/DropboxSaveModal';
@ -94,10 +98,12 @@ export default {
ImageModal, ImageModal,
SyncManagementModal, SyncManagementModal,
PublishManagementModal, PublishManagementModal,
WorkspaceManagementModal,
SponsorModal, SponsorModal,
// Providers // Providers
GooglePhotoModal, GooglePhotoModal,
GoogleDriveSaveModal, GoogleDriveSaveModal,
GoogleDriveWorkspaceModal,
GoogleDrivePublishModal, GoogleDrivePublishModal,
DropboxAccountModal, DropboxAccountModal,
DropboxSaveModal, DropboxSaveModal,
@ -178,6 +184,10 @@ export default {
height: 100%; height: 100%;
background-color: rgba(160, 160, 160, 0.5); background-color: rgba(160, 160, 160, 0.5);
overflow: auto; overflow: auto;
hr {
margin: 0.5em 0;
}
} }
.modal__inner-1 { .modal__inner-1 {

View File

@ -219,6 +219,9 @@ export default {
.navigation-bar__inner--right { .navigation-bar__inner--right {
float: right; float: right;
/* prevent from seeing wrapped buttons */
margin-bottom: 20px;
} }
.navigation-bar__inner--button { .navigation-bar__inner--button {

View File

@ -13,6 +13,7 @@
</div> </div>
<div class="side-bar__inner"> <div class="side-bar__inner">
<main-menu v-if="panel === 'menu'"></main-menu> <main-menu v-if="panel === 'menu'"></main-menu>
<workspaces-menu v-if="panel === 'workspaces'"></workspaces-menu>
<sync-menu v-else-if="panel === 'sync'"></sync-menu> <sync-menu v-else-if="panel === 'sync'"></sync-menu>
<publish-menu v-else-if="panel === 'publish'"></publish-menu> <publish-menu v-else-if="panel === 'publish'"></publish-menu>
<history-menu v-else-if="panel === 'history'"></history-menu> <history-menu v-else-if="panel === 'history'"></history-menu>
@ -33,6 +34,7 @@
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import Toc from './Toc'; import Toc from './Toc';
import MainMenu from './menus/MainMenu'; import MainMenu from './menus/MainMenu';
import WorkspacesMenu from './menus/WorkspacesMenu';
import SyncMenu from './menus/SyncMenu'; import SyncMenu from './menus/SyncMenu';
import PublishMenu from './menus/PublishMenu'; import PublishMenu from './menus/PublishMenu';
import HistoryMenu from './menus/HistoryMenu'; import HistoryMenu from './menus/HistoryMenu';
@ -43,6 +45,7 @@ import markdownConversionSvc from '../services/markdownConversionSvc';
const panelNames = { const panelNames = {
menu: 'Menu', menu: 'Menu',
workspaces: 'Workspaces',
help: 'Markdown cheat sheet', help: 'Markdown cheat sheet',
toc: 'Table of contents', toc: 'Table of contents',
sync: 'Synchronize', sync: 'Synchronize',
@ -56,6 +59,7 @@ export default {
components: { components: {
Toc, Toc,
MainMenu, MainMenu,
WorkspacesMenu,
SyncMenu, SyncMenu,
PublishMenu, PublishMenu,
HistoryMenu, HistoryMenu,
@ -67,7 +71,7 @@ export default {
}), }),
computed: { computed: {
panel() { panel() {
return this.$store.getters['data/localSettings'].sideBarPanel; return this.$store.getters['data/layoutSettings'].sideBarPanel;
}, },
panelName() { panelName() {
return panelNames[this.panel]; return panelNames[this.panel];

View File

@ -63,13 +63,13 @@ export default {
'setCurrentDiscussionId', 'setCurrentDiscussionId',
]), ]),
updateTops() { updateTops() {
const localSettings = this.$store.getters['data/localSettings']; const layoutSettings = this.$store.getters['data/layoutSettings'];
const minTop = -2; const minTop = -2;
let minCommentTop = minTop; let minCommentTop = minTop;
const getTop = (discussion, commentElt1, commentElt2, isCurrent) => { const getTop = (discussion, commentElt1, commentElt2, isCurrent) => {
const firstElt = commentElt1 || commentElt2; const firstElt = commentElt1 || commentElt2;
const secondElt = commentElt1 && commentElt2; const secondElt = commentElt1 && commentElt2;
const coordinates = localSettings.showEditor const coordinates = layoutSettings.showEditor
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end) ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end)); : editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
let commentTop = minTop; let commentTop = minTop;
@ -98,10 +98,9 @@ export default {
// Get the discussion top coordinates // Get the discussion top coordinates
const tops = {}; const tops = {};
const discussions = this.currentFileDiscussions; const discussions = this.currentFileDiscussions;
Object.keys(discussions) Object.entries(discussions)
.sort((id1, id2) => discussions[id1].end - discussions[id2].end) .sort(([, discussion1], [, discussion2]) => discussion1.end - discussion2.end)
.forEach((discussionId) => { .forEach(([discussionId, discussion]) => {
const discussion = this.currentFileDiscussions[discussionId];
if (discussion === this.currentDiscussion || discussion === this.newDiscussion) { if (discussion === this.currentDiscussion || discussion === this.newDiscussion) {
tops.current = getTop( tops.current = getTop(
discussion, discussion,
@ -123,8 +122,8 @@ export default {
() => this.updateTops(), () => this.updateTops(),
{ immediate: true }); { immediate: true });
const localSettings = this.$store.getters['data/localSettings']; const layoutSettings = this.$store.getters['data/layoutSettings'];
this.scrollerElt = localSettings.showEditor this.scrollerElt = layoutSettings.showEditor
? editorSvc.editorElt.parentNode ? editorSvc.editorElt.parentNode
: editorSvc.previewElt.parentNode; : editorSvc.previewElt.parentNode;

View File

@ -68,15 +68,15 @@ export default {
]), ]),
goToDiscussion(discussionId = this.currentDiscussionId) { goToDiscussion(discussionId = this.currentDiscussionId) {
this.setCurrentDiscussionId(discussionId); this.setCurrentDiscussionId(discussionId);
const localSettings = this.$store.getters['data/localSettings']; const layoutSettings = this.$store.getters['data/layoutSettings'];
const discussion = this.currentFileDiscussions[discussionId]; const discussion = this.currentFileDiscussions[discussionId];
const coordinates = localSettings.showEditor const coordinates = layoutSettings.showEditor
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end) ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end)); : editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
if (!coordinates) { if (!coordinates) {
this.$store.dispatch('notification/info', "Discussion can't be located in the file."); this.$store.dispatch('notification/info', "Discussion can't be located in the file.");
} else { } else {
const scrollerElt = localSettings.showEditor const scrollerElt = layoutSettings.showEditor
? editorSvc.editorElt.parentNode ? editorSvc.editorElt.parentNode
: editorSvc.previewElt.parentNode; : editorSvc.previewElt.parentNode;
let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2); let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2);

View File

@ -1,6 +1,8 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--history"> <div class="history side-bar__panel">
<a class="revision button flex flex--row" href="javascript:void(0)" v-for="revision in revisions" :key="revision.id" @click="open(revision)"> <div class="revision" v-for="revision in revisions" :key="revision.id">
<div class="history__spacer" v-if="revision.spacer"></div>
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
<div class="revision__icon"> <div class="revision__icon">
<user-image :user-id="revision.sub"></user-image> <user-image :user-id="revision.sub"></user-image>
</div> </div>
@ -9,8 +11,10 @@
<div class="revision__created">{{revision.created | formatTime}}</div> <div class="revision__created">{{revision.created | formatTime}}</div>
</div> </div>
</a> </a>
</div>
<div class="history__spacer history__spacer--last"></div>
<div class="flex flex--row flex--end" v-if="showMoreButton"> <div class="flex flex--row flex--end" v-if="showMoreButton">
<button class="revision__button button" @click="showMore">More</button> <button class="history__button button" @click="showMore">More</button>
</div> </div>
</div> </div>
</template> </template>
@ -33,6 +37,7 @@ let cachedFileId;
let revisionsPromise; let revisionsPromise;
let revisionContentPromises; let revisionContentPromises;
const pageSize = 50; const pageSize = 50;
const spacerThreshold = 12 * 60 * 60 * 1000; // 12h
export default { export default {
components: { components: {
@ -46,7 +51,15 @@ export default {
}), }),
computed: { computed: {
revisions() { revisions() {
return this.allRevisions.slice(0, this.showCount); let previousCreated = 0;
return this.allRevisions.slice(0, this.showCount).map((revision) => {
const revisionWithSpacer = {
...revision,
spacer: revision.created + spacerThreshold < previousCreated,
};
previousCreated = revision.created;
return revisionWithSpacer;
});
}, },
showMoreButton() { showMoreButton() {
return this.showCount < this.allRevisions.length; return this.showCount < this.allRevisions.length;
@ -171,11 +184,34 @@ export default {
<style lang="scss"> <style lang="scss">
@import '../common/variables.scss'; @import '../common/variables.scss';
.side-bar__panel--history { .history {
padding: 5px 5px 50px; padding: 5px 5px 50px;
} }
.revision { .history__button {
font-size: 14px;
margin-top: 0.5em;
}
.history__spacer {
position: relative;
height: 40px;
&::before {
content: '';
position: absolute;
height: 100%;
top: 0;
left: 24px;
border-left: 2px dotted $hr-color;
}
}
.history__spacer--last {
height: 20px;
}
.revision__button {
text-align: left; text-align: left;
padding: 15px; padding: 15px;
height: auto; height: auto;
@ -199,7 +235,7 @@ export default {
} }
} }
&:first-child::before { .revision:first-child &::before {
height: 67%; height: 67%;
top: 33%; top: 33%;
} }
@ -225,11 +261,6 @@ export default {
opacity: 0.5; opacity: 0.5;
} }
.revision__button {
font-size: 14px;
margin-top: 0.5em;
}
.layout--revision { .layout--revision {
.cledit-section *, .cledit-section *,
.cl-preview-section * { .cl-preview-section * {

View File

@ -11,6 +11,11 @@
</div> </div>
<span>Signed in as <b>{{loginToken.name}}</b>.</span> <span>Signed in as <b>{{loginToken.name}}</b>.</span>
</div> </div>
<menu-entry @click.native="setPanel('workspaces')">
<icon-database slot="icon"></icon-database>
<div>Workspaces</div>
<span>Switch to another workspace.</span>
</menu-entry>
<hr> <hr>
<menu-entry @click.native="setPanel('sync')"> <menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync> <icon-sync slot="icon"></icon-sync>
@ -22,17 +27,16 @@
<div>Publish</div> <div>Publish</div>
<span>Export your file to the web.</span> <span>Export your file to the web.</span>
</menu-entry> </menu-entry>
<hr>
<menu-entry @click.native="fileProperties">
<icon-view-list slot="icon"></icon-view-list>
<div>File properties</div>
<span>Add metadata and configure extensions.</span>
</menu-entry>
<menu-entry @click.native="history"> <menu-entry @click.native="history">
<icon-history slot="icon"></icon-history> <icon-history slot="icon"></icon-history>
<div>File history</div> <div>File history</div>
<span>Track and restore file revisions.</span> <span>Track and restore file revisions.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="fileProperties">
<icon-view-list slot="icon"></icon-view-list>
<div>File properties</div>
<span>Add metadata and configure extensions.</span>
</menu-entry>
<hr> <hr>
<menu-entry @click.native="setPanel('toc')"> <menu-entry @click.native="setPanel('toc')">
<icon-toc slot="icon"></icon-toc> <icon-toc slot="icon"></icon-toc>
@ -42,7 +46,6 @@
<icon-help-circle slot="icon"></icon-help-circle> <icon-help-circle slot="icon"></icon-help-circle>
Markdown cheat sheet Markdown cheat sheet
</menu-entry> </menu-entry>
<hr>
<menu-entry @click.native="print"> <menu-entry @click.native="print">
<icon-printer slot="icon"></icon-printer> <icon-printer slot="icon"></icon-printer>
Print Print
@ -122,9 +125,10 @@ export default {
history() { history() {
const loginToken = this.$store.getters['data/loginToken']; const loginToken = this.$store.getters['data/loginToken'];
if (!loginToken) { if (!loginToken) {
this.$store.dispatch('modal/signInForHistory') this.$store.dispatch('modal/signInForHistory', {
.then(() => googleHelper.signin()) onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()) .then(() => syncSvc.requestSync()),
})
.catch(() => { }); // Cancel .catch(() => { }); // Cancel
} else { } else {
this.setPanel('history'); this.setPanel('history');

View File

@ -0,0 +1,60 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div class="workspace" v-for="(workspace, id) in workspaces" :key="id">
<menu-entry :href="workspace.url" target="_blank">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider>
<div class="workspace__name">{{workspace.name}}</div>
<span>{{workspace.url}}</span>
</menu-entry>
</div>
<hr>
<menu-entry @click.native="addGoogleDriveWorkspace">
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span>
</menu-entry>
<menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database>
<span>Manage workspaces</span>
</menu-entry>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper';
export default {
components: {
MenuEntry,
},
computed: {
...mapGetters('data', [
'workspaces',
]),
},
methods: {
addGoogleDriveWorkspace() {
return googleHelper.addDriveAccount()
.then(token => this.$store.dispatch('modal/open', {
type: 'googleDriveWorkspace',
token,
}))
.catch(() => {}); // Cancel
},
manageWorkspaces() {
return this.$store.dispatch('modal/open', 'workspaceManagement');
},
},
};
</script>
<style lang="scss">
.workspace__name {
font-weight: bold;
.menu-entry div & {
text-decoration: none;
}
}
</style>

View File

@ -24,7 +24,7 @@
text-decoration: underline; text-decoration: underline;
text-decoration-skip: ink; text-decoration-skip: ink;
&.menu-entry__sponsor { .menu-entry__sponsor {
text-decoration: none; text-decoration: none;
} }
} }

View File

@ -37,10 +37,9 @@ export default modalTemplate({
computed: { computed: {
googlePhotosTokens() { googlePhotosTokens() {
const googleToken = this.$store.getters['data/googleTokens']; const googleToken = this.$store.getters['data/googleTokens'];
return Object.keys(googleToken) return Object.entries(googleToken)
.map(sub => googleToken[sub]) .filter(([, token]) => token.isPhotos)
.filter(token => token.isPhotos) .sort(([, token1], [, token2]) => token1.name.localeCompare(token2.name));
.sort((token1, token2) => token1.name.localeCompare(token2.name));
}, },
}, },
methods: { methods: {

View File

@ -4,7 +4,7 @@
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p> <p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not published yet.</p> <p v-else><b>{{currentFileName}}</b> is not published yet.</p>
<div> <div>
<div v-for="location in publishLocations" :key="location.id" class="publish-entry flex flex--row flex--align-center"> <div class="publish-entry flex flex--row flex--align-center" v-for="location in publishLocations" :key="location.id">
<div class="publish-entry__icon flex flex--column flex--center"> <div class="publish-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider> <icon-provider :provider-id="location.providerId"></icon-provider>
</div> </div>
@ -26,8 +26,7 @@
</div> </div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button> <button class="button" @click="config.resolve()">Close</button>
<button class="button" @click="config.resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,7 +4,7 @@
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p> <p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p> <p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
<div> <div>
<div v-for="location in syncLocations" :key="location.id" class="sync-entry flex flex--row flex--align-center"> <div class="sync-entry flex flex--row flex--align-center" v-for="location in syncLocations" :key="location.id">
<div class="sync-entry__icon flex flex--column flex--center"> <div class="sync-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider> <icon-provider :provider-id="location.providerId"></icon-provider>
</div> </div>
@ -26,8 +26,7 @@
</div> </div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button> <button class="button" @click="config.resolve()">Close</button>
<button class="button" @click="config.resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -95,12 +95,12 @@ export default {
(allTemplates) => { (allTemplates) => {
const templates = {}; const templates = {};
// Sort templates by name // Sort templates by name
Object.keys(allTemplates) Object.entries(allTemplates)
.sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name)) .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
.forEach((id) => { .forEach(([id, template]) => {
const template = utils.deepCopy(allTemplates[id]); const templateClone = utils.deepCopy(template);
fillEmptyFields(template); fillEmptyFields(templateClone);
templates[id] = template; templates[id] = templateClone;
}); });
this.templates = templates; this.templates = templates;
this.selectedId = this.config.selectedId; this.selectedId = this.config.selectedId;

View File

@ -0,0 +1,133 @@
<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 workspaces" :key="id">
<div class="workspace-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="workspace.providerId"></icon-provider>
</div>
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc.stop="submitEdit(true)" v-model="editingName">
<div class="workspace-entry__name" v-else>
{{workspace.name}}
</div>
<div class="workspace-entry__buttons flex flex--row flex--center">
<button class="workspace-entry__button button" @click="edit(id)">
<icon-pen></icon-pen>
</button>
<a class="workspace-entry__button button" :href="workspace.url" target="_blank">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="workspace-entry__button button" @click="remove(id)">
<icon-delete></icon-delete>
</button>
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
</div>
</modal-inner>
</template>
<script>
import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
export default {
components: {
ModalInner,
},
data: () => ({
editedId: null,
editingName: '',
}),
computed: {
...mapGetters('modal', [
'config',
]),
...mapGetters('data', [
'workspaces',
]),
},
methods: {
edit(id) {
this.editedId = id;
this.editingName = this.workspaces[id].name;
},
submitEdit(cancel) {
const workspace = this.workspaces[this.editedId];
if (workspace && !cancel && this.editingName) {
this.$store.dispatch('data/patchWorkspaces', {
[this.editedId]: {
...workspace,
name: this.editingName,
},
});
} else {
this.editingName = workspace.name;
}
this.editedId = null;
},
remove(id) {
const workspaces = {
...this.workspaces,
};
delete workspaces[id];
this.$store.dispatch('data/setWorkspaces', workspaces);
},
},
};
</script>
<style lang="scss">
@import '../common/variables.scss';
.modal__inner-1--workspace-management {
max-width: 560px;
}
.workspace-entry {
text-align: left;
padding-left: 10px;
margin: 15px 0;
height: auto;
font-size: 17px;
line-height: 1.5;
text-transform: none;
&:last-child {
border-bottom: none;
}
}
.workspace-entry__icon {
height: 20px;
width: 20px;
margin-right: 12px;
flex: none;
}
.workspace-entry__name {
width: 100%;
overflow: hidden;
font-weight: bold;
}
.workspace-entry__buttons {
margin-left: 0.75rem;
}
.workspace-entry__button {
width: 36px;
height: 36px;
padding: 6px;
background-color: transparent;
opacity: 0.75;
&:active,
&:focus,
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -32,9 +32,11 @@ export default {
sponsor() { sponsor() {
Promise.resolve() Promise.resolve()
.then(() => !this.$store.getters['data/loginToken'] && .then(() => !this.$store.getters['data/loginToken'] &&
this.$store.dispatch('modal/signInForSponsorship') // If user has to sign in // If user has to sign in
.then(() => googleHelper.signin()) this.$store.dispatch('modal/signInForSponsorship', {
.then(() => syncSvc.requestSync())) onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()),
})
.then(() => { .then(() => {
if (!this.$store.getters.isSponsor) { if (!this.$store.getters.isSponsor) {
this.$store.dispatch('modal/open', 'sponsor'); this.$store.dispatch('modal/open', 'sponsor');

View File

@ -40,8 +40,7 @@ export default (desc) => {
}, },
}, },
}; };
Object.keys(desc.computedLocalSettings || {}).forEach((key) => { Object.entries(desc.computedLocalSettings || {}).forEach(([key, id]) => {
const id = desc.computedLocalSettings[key];
component.computed[key] = { component.computed[key] = {
get() { get() {
return store.getters['data/localSettings'][id]; return store.getters['data/localSettings'][id];
@ -56,10 +55,10 @@ export default (desc) => {
component.computed.allTemplates = () => { component.computed.allTemplates = () => {
const allTemplates = store.getters['data/allTemplates']; const allTemplates = store.getters['data/allTemplates'];
const sortedTemplates = {}; const sortedTemplates = {};
Object.keys(allTemplates) Object.entries(allTemplates)
.sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name)) .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
.forEach((templateId) => { .forEach(([templateId, template]) => {
sortedTemplates[templateId] = allTemplates[templateId]; sortedTemplates[templateId] = template;
}); });
return sortedTemplates; return sortedTemplates;
}; };

View File

@ -0,0 +1,59 @@
<template>
<modal-inner aria-label="Add Google Drive workspace">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>This will create a workspace synchronized with a <b>Google Drive</b> folder.</p>
<form-entry label="Folder ID (optional)">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keyup.enter="resolve()">
<div class="form-entry__info">
If no folder ID is supplied, a new workspace folder will be created in your root folder.
</div>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="openFolder">Choose folder</a>
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import googleHelper from '../../../services/providers/helpers/googleHelper';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
fileId: '',
}),
computedLocalSettings: {
folderId: 'googleDriveFolderId',
},
methods: {
openFolder() {
return this.$store.dispatch(
'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => {
this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id,
});
}));
},
resolve() {
const url = utils.addQueryParams('app', {
providerId: 'googleDriveWorkspace',
folderId: this.folderId,
sub: this.config.token.sub,
});
this.config.resolve();
window.open(url);
},
},
});
</script>

View File

@ -0,0 +1,13 @@
export default () => ({
showNavigationBar: true,
showEditor: true,
showSidePreview: true,
showStatusBar: true,
showSideBar: false,
showExplorer: false,
scrollSync: true,
focusMode: false,
findCaseSensitive: false,
findUseRegexp: false,
sideBarPanel: 'menu',
});

View File

@ -1,16 +1,5 @@
export default () => ({ export default () => ({
welcomeFileHashes: {}, welcomeFileHashes: {},
showNavigationBar: true,
showEditor: true,
showSidePreview: true,
showStatusBar: true,
showSideBar: false,
showExplorer: false,
scrollSync: true,
focusMode: false,
findCaseSensitive: false,
findUseRegexp: false,
sideBarPanel: 'menu',
htmlExportTemplate: 'styledHtml', htmlExportTemplate: 'styledHtml',
pdfExportTemplate: 'styledHtml', pdfExportTemplate: 'styledHtml',
pandocExportFormat: 'pdf', pandocExportFormat: 'pdf',

View File

@ -0,0 +1,5 @@
export default () => ({
main: {
name: 'Main workspace',
},
});

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

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z" />
</svg>
</template>

View File

@ -1,11 +1,30 @@
<template> <template>
<div class="icon-provider" :class="['icon-provider--' + providerId]"> <div class="icon-provider" :class="'icon-provider--' + classState">
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: ['providerId'], props: ['providerId'],
computed: {
classState() {
switch (this.providerId) {
case 'googleDrive':
case 'googleDriveWorkspace':
return 'google-drive';
case 'googlePhotos':
return 'google-photos';
case 'dropboxRestricted':
return 'dropbox';
case 'gist':
return 'github';
case 'bloggerPage':
return 'blogger';
default:
return this.providerId;
}
},
},
}; };
</script> </script>
@ -22,21 +41,19 @@ export default {
background-image: url(../assets/iconStackedit.svg); background-image: url(../assets/iconStackedit.svg);
} }
.icon-provider--googleDrive { .icon-provider--google-drive {
background-image: url(../assets/iconGoogleDrive.svg); background-image: url(../assets/iconGoogleDrive.svg);
} }
.icon-provider--googlePhotos { .icon-provider--google-photos {
background-image: url(../assets/iconGooglePhotos.svg); background-image: url(../assets/iconGooglePhotos.svg);
} }
.icon-provider--github, .icon-provider--github {
.icon-provider--gist {
background-image: url(../assets/iconGithub.svg); background-image: url(../assets/iconGithub.svg);
} }
.icon-provider--dropbox, .icon-provider--dropbox {
.icon-provider--dropboxRestricted {
background-image: url(../assets/iconDropbox.svg); background-image: url(../assets/iconDropbox.svg);
} }
@ -44,8 +61,7 @@ export default {
background-image: url(../assets/iconWordpress.svg); background-image: url(../assets/iconWordpress.svg);
} }
.icon-provider--blogger, .icon-provider--blogger {
.icon-provider--bloggerPage {
background-image: url(../assets/iconBlogger.svg); background-image: url(../assets/iconBlogger.svg);
} }

View File

@ -48,6 +48,7 @@ import Redo from './Redo';
import ContentSave from './ContentSave'; import ContentSave from './ContentSave';
import Message from './Message'; import Message from './Message';
import History from './History'; import History from './History';
import Database from './Database';
Vue.component('iconProvider', Provider); Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
@ -98,3 +99,4 @@ Vue.component('iconRedo', Redo);
Vue.component('iconContentSave', ContentSave); Vue.component('iconContentSave', ContentSave);
Vue.component('iconMessage', Message); Vue.component('iconMessage', Message);
Vue.component('iconHistory', History); Vue.component('iconHistory', History);
Vue.component('iconDatabase', Database);

View File

@ -15,8 +15,7 @@ export default {
// Parse JSON value // Parse JSON value
const parsedValue = JSON.parse(jsonValue); const parsedValue = JSON.parse(jsonValue);
Object.keys(parsedValue).forEach((id) => { Object.entries(parsedValue).forEach(([id, value]) => {
const value = parsedValue[id];
if (value) { if (value) {
const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/); const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/);
if (v4Match) { if (v4Match) {
@ -56,8 +55,8 @@ export default {
}); });
// Go through the maps // Go through the maps
Object.keys(nameMap).forEach(externalId => store.dispatch('createFile', { Object.entries(nameMap).forEach(([externalId, name]) => store.dispatch('createFile', {
name: nameMap[externalId], name,
parentId: folderIdMap[parentIdMap[externalId]], parentId: folderIdMap[parentIdMap[externalId]],
text: textMap[externalId], text: textMap[externalId],
properties: propertiesMap[externalId], properties: propertiesMap[externalId],

View File

@ -32,6 +32,16 @@ const diffMatchPatch = new DiffMatchPatch();
let instantPreview = true; let instantPreview = true;
let tokens; let tokens;
class SectionDesc {
constructor(section, previewElt, tocElt, html) {
this.section = section;
this.editorElt = section.elt;
this.previewElt = previewElt;
this.tocElt = tocElt;
this.html = html;
}
}
// Use a vue instance as an event bus // Use a vue instance as an event bus
const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, { const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, {
// Elements // Elements
@ -88,7 +98,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
return this.parsingCtx.sections; return this.parsingCtx.sections;
}, },
getCursorFocusRatio: () => { getCursorFocusRatio: () => {
if (store.getters['data/localSettings'].focusMode) { if (store.getters['data/layoutSettings'].focusMode) {
return 1; return 1;
} }
return 0.15; return 0.15;
@ -128,12 +138,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
sectionDescIdx += 1; sectionDescIdx += 1;
if (sectionDesc.editorElt !== section.elt) { if (sectionDesc.editorElt !== section.elt) {
// Force textToPreviewDiffs computation // Force textToPreviewDiffs computation
sectionDesc = { sectionDesc = new SectionDesc(
...sectionDesc, section, sectionDesc.previewElt, sectionDesc.tocElt, sectionDesc.html);
section,
editorElt: section.elt,
textToPreviewDiffs: null,
};
} }
newSectionDescList.push(sectionDesc); newSectionDescList.push(sectionDesc);
previewHtml += sectionDesc.html; previewHtml += sectionDesc.html;
@ -183,16 +189,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
} }
previewHtml += html; previewHtml += html;
newSectionDescList.push({ newSectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html));
section,
editorElt: section.elt,
previewElt: sectionPreviewElt,
tocElt: sectionTocElt,
html,
});
} }
} }
}); });
this.sectionDescList = newSectionDescList; this.sectionDescList = newSectionDescList;
this.previewHtml = previewHtml.replace(/^\s+|\s+$/g, ''); this.previewHtml = previewHtml.replace(/^\s+|\s+$/g, '');
this.$emit('previewHtml', this.previewHtml); this.$emit('previewHtml', this.previewHtml);
@ -275,7 +276,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
}, },
/** /**
* Save editor selection/scroll state into the current file content. * Save editor selection/scroll state into the store.
*/ */
saveContentState: allowDebounce(() => { saveContentState: allowDebounce(() => {
const scrollPosition = editorSvc.getScrollPosition() || const scrollPosition = editorSvc.getScrollPosition() ||
@ -342,12 +343,12 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
this.tocElt = tocElt; this.tocElt = tocElt;
this.createClEditor(editorElt); this.createClEditor(editorElt);
this.clEditor.on('contentChanged', (content, diffs, sectionList) => { this.clEditor.on('contentChanged', (content, diffs, sectionList) => {
const parsingCtx = { this.parsingCtx = {
...this.parsingCtx, ...this.parsingCtx,
sectionList, sectionList,
}; };
this.parsingCtx = parsingCtx;
}); });
this.clEditor.undoMgr.on('undoStateChange', () => { this.clEditor.undoMgr.on('undoStateChange', () => {
const canUndo = this.clEditor.undoMgr.canUndo(); const canUndo = this.clEditor.undoMgr.canUndo();
@ -447,11 +448,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
}; };
const triggerImgCacheGc = debounce(() => { const triggerImgCacheGc = debounce(() => {
Object.keys(imgCache).forEach((src) => { Object.entries(imgCache).forEach(([src, entries]) => {
const entries = imgCache[src] // Filter entries that are not attached to the DOM
.filter(imgElt => this.editorElt.contains(imgElt)); const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt));
if (entries.length) { if (filteredEntries.length) {
imgCache[src] = entries; imgCache[src] = filteredEntries;
} else { } else {
delete imgCache[src]; delete imgCache[src];
} }

View File

@ -45,8 +45,7 @@ function syncDiscussionMarkers(content, writeOffsets) {
...newDiscussion, ...newDiscussion,
}; };
} }
Object.keys(discussionMarkers).forEach((markerKey) => { Object.entries(discussionMarkers).forEach(([markerKey, marker]) => {
const marker = discussionMarkers[markerKey];
// Remove marker if discussion was removed // Remove marker if discussion was removed
const discussion = discussions[marker.discussionId]; const discussion = discussions[marker.discussionId];
if (!discussion) { if (!discussion) {
@ -55,8 +54,7 @@ function syncDiscussionMarkers(content, writeOffsets) {
} }
}); });
Object.keys(discussions).forEach((discussionId) => { Object.entries(discussions).forEach(([discussionId, discussion]) => {
const discussion = discussions[discussionId];
getDiscussionMarkers(discussion, discussionId, writeOffsets getDiscussionMarkers(discussion, discussionId, writeOffsets
? (marker) => { ? (marker) => {
discussion[marker.offsetName] = marker.offset; discussion[marker.offsetName] = marker.offset;
@ -73,8 +71,8 @@ function syncDiscussionMarkers(content, writeOffsets) {
} }
function removeDiscussionMarkers() { function removeDiscussionMarkers() {
Object.keys(discussionMarkers).forEach((markerKey) => { Object.entries(discussionMarkers).forEach(([, marker]) => {
clEditor.removeMarker(discussionMarkers[markerKey]); clEditor.removeMarker(marker);
}); });
discussionMarkers = {}; discussionMarkers = {};
markerKeys = []; markerKeys = [];
@ -138,25 +136,6 @@ export default {
isChangePatch = false; isChangePatch = false;
}); });
clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false)); clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false));
// Track new discussions (not sure it's a good idea)
// store.watch(
// () => store.getters['content/current'].discussions,
// (discussions) => {
// const oldDiscussionIds = discussionIds;
// discussionIds = {};
// let hasNewDiscussion = false;
// Object.keys(discussions).forEach((discussionId) => {
// discussionIds[discussionId] = true;
// if (!oldDiscussionIds[discussionId]) {
// hasNewDiscussion = true;
// }
// });
// if (hasNewDiscussion) {
// const content = store.getters['content/current'];
// currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap);
// }
// });
}, },
initClEditorInternal(opts) { initClEditorInternal(opts) {
const content = store.getters['content/current']; const content = store.getters['content/current'];
@ -241,9 +220,10 @@ export default {
classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId }); classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId });
editorClassAppliers[discussionId] = classApplier; editorClassAppliers[discussionId] = classApplier;
}); });
Object.keys(oldEditorClassAppliers).forEach((discussionId) => { // Clean unused class appliers
Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => {
if (!editorClassAppliers[discussionId]) { if (!editorClassAppliers[discussionId]) {
oldEditorClassAppliers[discussionId].stop(); classApplier.stop();
} }
}); });
@ -255,9 +235,10 @@ export default {
classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId }); classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId });
previewClassAppliers[discussionId] = classApplier; previewClassAppliers[discussionId] = classApplier;
}); });
Object.keys(oldPreviewClassAppliers).forEach((discussionId) => { // Clean unused class appliers
Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => {
if (!previewClassAppliers[discussionId]) { if (!previewClassAppliers[discussionId]) {
oldPreviewClassAppliers[discussionId].stop(); classApplier.stop();
} }
}); });
}, },

View File

@ -6,36 +6,25 @@ import store from '../store';
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();
export default { export default {
/**
* Get element and dimension that handles scrolling.
*/
getObjectToScroll() {
let elt = this.editorElt.parentNode;
let dimensionKey = 'editorDimension';
if (!store.getters['layout/styles'].showEditor) {
elt = this.previewElt.parentNode;
dimensionKey = 'previewDimension';
}
return {
elt,
dimensionKey,
};
},
/** /**
* Get an object describing the position of the scroll bar in the file. * Get an object describing the position of the scroll bar in the file.
*/ */
getScrollPosition() { getScrollPosition(elt = store.getters['layout/styles'].showEditor
const objToScroll = this.getObjectToScroll(); ? this.editorElt
const scrollTop = objToScroll.elt.scrollTop; : this.previewElt,
) {
const dimensionKey = elt === this.editorElt
? 'editorDimension'
: 'previewDimension';
const scrollTop = elt.parentNode.scrollTop;
let result; let result;
if (this.sectionDescMeasuredList) { if (this.sectionDescMeasuredList) {
this.sectionDescMeasuredList.some((sectionDesc, sectionIdx) => { this.sectionDescMeasuredList.some((sectionDesc, sectionIdx) => {
if (scrollTop >= sectionDesc[objToScroll.dimensionKey].endOffset) { if (scrollTop >= sectionDesc[dimensionKey].endOffset) {
return false; return false;
} }
const posInSection = (scrollTop - sectionDesc[objToScroll.dimensionKey].startOffset) / const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) /
(sectionDesc[objToScroll.dimensionKey].height || 1); (sectionDesc[dimensionKey].height || 1);
result = { result = {
sectionIdx, sectionIdx,
posInSection, posInSection,

View File

@ -15,11 +15,11 @@ const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
class Connection { class Connection {
constructor() { constructor(dbName) {
this.getTxCbs = []; this.getTxCbs = [];
// Init connexion // Init connection
const request = indexedDB.open('stackedit-db', dbVersion); const request = indexedDB.open(dbName, dbVersion);
request.onerror = () => { request.onerror = () => {
throw new Error("Can't connect to IndexedDB."); throw new Error("Can't connect to IndexedDB.");
@ -39,7 +39,7 @@ class Connection {
const oldVersion = event.oldVersion || 0; const oldVersion = event.oldVersion || 0;
// We don't use 'break' in this switch statement, // We don't use 'break' in this switch statement,
// the fall-through behaviour is what we want. // the fall-through behavior is what we want.
/* eslint-disable no-fallthrough */ /* eslint-disable no-fallthrough */
switch (oldVersion) { switch (oldVersion) {
case 0: case 0:
@ -80,21 +80,174 @@ class Connection {
} }
} }
const hashMap = {};
utils.types.forEach((type) => {
hashMap[type] = Object.create(null);
});
const contentTypes = { const contentTypes = {
content: true, content: true,
contentState: true, contentState: true,
syncedContent: true, syncedContent: true,
}; };
const hashMap = {};
utils.types.forEach((type) => {
hashMap[type] = Object.create(null);
});
const lsHashMap = Object.create(null);
const localDbSvc = { const localDbSvc = {
lastTx: 0, lastTx: 0,
hashMap, hashMap,
connection: new Connection(), connection: null,
/**
* Create the connection and start syncing.
*/
init() {
// Create the connection
this.connection = new Connection(store.getters['data/dbName']);
// Load the DB
return localDbSvc.sync()
.then(() => {
// If exportBackup parameter was provided
if (exportBackup) {
const backup = JSON.stringify(store.getters.allItemMap);
const blob = new Blob([backup], {
type: 'text/plain;charset=utf-8',
});
FileSaver.saveAs(blob, 'StackEdit workspace.json');
return;
}
// Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile);
const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes;
if (!welcomeFileHashes[hash]) {
store.dispatch('data/patchLocalSettings', {
welcomeFileHashes: {
...welcomeFileHashes,
[hash]: 1,
},
});
}
// If app was last opened 7 days ago and synchronization is off
if (!store.getters['data/loginToken'] &&
(utils.lastOpened + utils.cleanTrashAfter < Date.now())
) {
// Clean files
store.getters['file/items']
.filter(file => file.parentId === 'trash') // If file is in the trash
.forEach(file => store.dispatch('deleteFile', file.id));
}
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
location.hash = '';
store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken'];
// Force check sponsorship after a few seconds
const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', {
...loginToken,
expiresOn: currentDate - checkSponsorshipAfter,
});
}
}
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// watch current file changing
store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file
.then(ifNoId(() => store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})))
.then((currentFile) => {
// Fix current file ID
if (store.getters['file/current'].id !== currentFile.id) {
store.commit('file/setCurrentId', currentFile.id);
// Wait for the next watch tick
return null;
}
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
// Load syncedContent from DB
.then(() => localDbSvc.loadSyncedContent(currentFile.id))
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`))
.then(
() => {
// Set last opened file
store.dispatch('data/setLastOpenedId', currentFile.id);
// Cancel new discussion
store.commit('discussion/setCurrentDiscussionId');
// Open the gutter if file contains discussions
store.commit('discussion/setCurrentDiscussionId',
store.getters['discussion/nextDiscussionId']);
},
(err) => {
// Failure (content is not available), go back to previous file
const lastOpenedFile = store.getters['file/lastOpened'];
store.commit('file/setCurrentId', lastOpenedFile.id);
throw err;
},
);
})
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}),
{
immediate: true,
});
});
},
/**
* Sync data items stored in the localStorage.
*/
syncLocalStorage() {
utils.localStorageDataIds.forEach((id) => {
const key = `data/${id}`;
// Skip reloading the layoutSettings
if (id !== 'layoutSettings' || !lsHashMap[id]) {
try {
// Try to parse the item from the localStorage
const storedItem = JSON.parse(localStorage.getItem(key));
if (storedItem.hash && lsHashMap[id] !== storedItem.hash) {
// Item has changed, replace it in the store
store.commit('data/setItem', storedItem);
lsHashMap[id] = storedItem.hash;
}
} catch (e) {
// Ignore parsing issue
}
}
// Write item if different from stored one
const item = store.state.data.lsItemMap[id];
if (item && item.hash !== lsHashMap[id]) {
localStorage.setItem(key, JSON.stringify(item));
lsHashMap[id] = item.hash;
}
});
},
/** /**
* Return a promise that will be resolved once the synchronization between the store and the * Return a promise that will be resolved once the synchronization between the store and the
@ -103,9 +256,15 @@ const localDbSvc = {
*/ */
sync() { sync() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Create the DB transaction
this.connection.createTx((tx) => { this.connection.createTx((tx) => {
// Look for DB changes and apply them to the store
this.readAll(tx, (storeItemMap) => { this.readAll(tx, (storeItemMap) => {
// Persist all the store changes into the DB
this.writeAll(storeItemMap, tx); this.writeAll(storeItemMap, tx);
// Sync localStorage
this.syncLocalStorage();
// Done
resolve(); resolve();
}); });
}, () => reject(new Error('Local DB access error.'))); }, () => reject(new Error('Local DB access error.')));
@ -186,8 +345,7 @@ const localDbSvc = {
}); });
// Put changes // Put changes
Object.keys(storeItemMap).forEach((id) => { Object.entries(storeItemMap).forEach(([, storeItem]) => {
const storeItem = storeItemMap[id];
// Store object has changed // Store object has changed
if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) { if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) {
const item = { const item = {
@ -266,11 +424,11 @@ const localDbSvc = {
return this.sync() return this.sync()
.then(() => { .then(() => {
// Keep only last opened files in memory // Keep only last opened files in memory
const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']); const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']);
Object.keys(contentTypes).forEach((type) => { Object.keys(contentTypes).forEach((type) => {
store.getters[`${type}/items`].forEach((item) => { store.getters[`${type}/items`].forEach((item) => {
const [fileId] = item.id.split('/'); const [fileId] = item.id.split('/');
if (!lastOpenedFileIds.has(fileId)) { if (!lastOpenedFileIdSet.has(fileId)) {
// Remove item from the store // Remove item from the store
store.commit(`${type}/deleteItem`, item.id); store.commit(`${type}/deleteItem`, item.id);
} }
@ -303,119 +461,4 @@ const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
localDbSvc.loadSyncedContent = loader('syncedContent'); localDbSvc.loadSyncedContent = loader('syncedContent');
localDbSvc.loadContentState = loader('contentState'); localDbSvc.loadContentState = loader('contentState');
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// Load the DB on boot
localDbSvc.sync()
.then(() => {
if (exportBackup) {
const backup = JSON.stringify(store.getters.allItemMap);
const blob = new Blob([backup], {
type: 'text/plain;charset=utf-8',
});
FileSaver.saveAs(blob, 'StackEdit workspace.json');
return;
}
// Set the ready flag
store.commit('setReady');
// Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile);
const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes;
if (!welcomeFileHashes[hash]) {
store.dispatch('data/patchLocalSettings', {
welcomeFileHashes: {
...welcomeFileHashes,
[hash]: 1,
},
});
}
// If app was last opened 7 days ago and synchronization is off
if (!store.getters['data/loginToken'] &&
(utils.lastOpened + utils.cleanTrashAfter < Date.now())
) {
// Clean files
store.getters['file/items']
.filter(file => file.parentId === 'trash') // If file is in the trash
.forEach(file => store.dispatch('deleteFile', file.id));
}
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
location.hash = '';
store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken'];
// Force check sponsorship after a few seconds
const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', {
...loginToken,
expiresOn: currentDate - checkSponsorshipAfter,
});
}
}
// watch file changing
store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file
.then(ifNoId(() => store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})))
.then((currentFile) => {
// Fix current file ID
if (store.getters['file/current'].id !== currentFile.id) {
store.commit('file/setCurrentId', currentFile.id);
// Wait for the next watch tick
return null;
}
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
// Load syncedContent from DB
.then(() => localDbSvc.loadSyncedContent(currentFile.id))
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`))
.then(
() => {
// Set last opened file
store.dispatch('data/setLastOpenedId', currentFile.id);
// Cancel new discussion
store.commit('discussion/setCurrentDiscussionId');
// Open the gutter if file contains discussions
store.commit('discussion/setCurrentDiscussionId',
store.getters['discussion/nextDiscussionId']);
},
(err) => {
// Failure (content is not available), go back to previous file
const lastOpenedFile = store.getters['file/lastOpened'];
store.commit('file/setCurrentId', lastOpenedFile.id);
throw err;
},
);
})
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}),
{
immediate: true,
});
});
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
export default localDbSvc; export default localDbSvc;

View File

@ -20,15 +20,13 @@ const languageAliases = ({
ps1: 'powershell', ps1: 'powershell',
psm1: 'powershell', psm1: 'powershell',
}); });
Object.keys(languageAliases).forEach((alias) => { Object.entries(languageAliases).forEach(([alias, language]) => {
const language = languageAliases[alias];
Prism.languages[alias] = Prism.languages[language]; Prism.languages[alias] = Prism.languages[language];
}); });
// Add programming language parsing capability to markdown fences // Add programming language parsing capability to markdown fences
const insideFences = {}; const insideFences = {};
Object.keys(Prism.languages).forEach((name) => { Object.entries(Prism.languages).forEach(([name, language]) => {
const language = Prism.languages[name];
if (Prism.util.type(language) === 'Object') { if (Prism.util.type(language) === 'Object') {
insideFences[`language-${name}`] = { insideFences[`language-${name}`] = {
pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`), pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`),

View File

@ -190,8 +190,7 @@ export default {
}, },
}; };
Object.keys(defs).forEach((name) => { Object.entries(defs).forEach(([name, def]) => {
const def = defs[name];
grammars.main[name] = def; grammars.main[name] = def;
grammars.list[name] = def; grammars.list[name] = def;
grammars.blockquote[name] = def; grammars.blockquote[name] = def;
@ -396,8 +395,7 @@ export default {
rest.linkref.inside['cl cl-underlined-text'].inside = inside; rest.linkref.inside['cl cl-underlined-text'].inside = inside;
// Wrap any other characters to allow paragraph folding // Wrap any other characters to allow paragraph folding
Object.keys(grammars).forEach((key) => { Object.entries(grammars).forEach(([, grammar]) => {
const grammar = grammars[key];
grammar.rest = grammar.rest || {}; grammar.rest = grammar.rest || {};
grammar.rest.p = /.+/; grammar.rest.p = /.+/;
}); });

View File

@ -193,12 +193,8 @@ export default {
const url = utils.addQueryParams(config.url, config.params); const url = utils.addQueryParams(config.url, config.params);
xhr.open(config.method || 'GET', url); xhr.open(config.method || 'GET', url);
Object.keys(config.headers).forEach((key) => { Object.entries(config.headers).forEach(([key, value]) =>
const value = config.headers[key]; value && xhr.setRequestHeader(key, `${value}`));
if (value) {
xhr.setRequestHeader(key, `${value}`);
}
});
if (config.blob) { if (config.blob) {
xhr.responseType = 'blob'; xhr.responseType = 'blob';
} }

View File

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

View File

@ -67,8 +67,7 @@ store.watch(
Mousetrap.reset(); Mousetrap.reset();
const shortcuts = computedSettings.shortcuts; const shortcuts = computedSettings.shortcuts;
Object.keys(shortcuts).forEach((key) => { Object.entries(shortcuts).forEach(([key, shortcut]) => {
const shortcut = shortcuts[key];
if (shortcut) { if (shortcut) {
const method = `${shortcut.method || shortcut}`; const method = `${shortcut.method || shortcut}`;
let params = shortcut.params || []; let params = shortcut.params || [];

View File

@ -7,6 +7,10 @@ export default providerRegistry.register({
getToken() { getToken() {
return store.getters['data/loginToken']; return store.getters['data/loginToken'];
}, },
initWorkspace() {
// Nothing to do since the main workspace isn't necessarily synchronized
return Promise.resolve();
},
getChanges(token) { getChanges(token) {
return googleHelper.getChanges(token) return googleHelper.getChanges(token)
.then((result) => { .then((result) => {
@ -44,8 +48,10 @@ export default providerRegistry.register({
saveItem(token, item, syncData, ifNotTooLate) { saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.uploadAppDataFile( return googleHelper.uploadAppDataFile(
token, token,
JSON.stringify(item), ['appDataFolder'], JSON.stringify(item),
null, ['appDataFolder'],
undefined,
undefined,
syncData && syncData.id, syncData && syncData.id,
ifNotTooLate, ifNotTooLate,
) )
@ -100,6 +106,7 @@ export default providerRegistry.register({
hash: item.hash, hash: item.hash,
}), }),
['appDataFolder'], ['appDataFolder'],
undefined,
JSON.stringify(item), JSON.stringify(item),
syncData && syncData.id, syncData && syncData.id,
ifNotTooLate, ifNotTooLate,
@ -116,6 +123,9 @@ export default providerRegistry.register({
}, },
listRevisions(token, fileId) { listRevisions(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.getFileRevisions(token, syncData.id) return googleHelper.getFileRevisions(token, syncData.id)
.then(revisions => revisions.map(revision => ({ .then(revisions => revisions.map(revision => ({
id: revision.id, id: revision.id,
@ -125,6 +135,9 @@ export default providerRegistry.register({
}, },
getRevisionContent(token, fileId, revisionId) { getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.downloadFileRevision(token, syncData.id, revisionId) return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => JSON.parse(content)); .then(content => JSON.parse(content));
}, },

View File

@ -32,6 +32,7 @@ export default providerRegistry.register({
token, token,
name, name,
parents, parents,
undefined,
providerUtils.serializeContent(content), providerUtils.serializeContent(content),
undefined, undefined,
syncLocation.driveFileId, syncLocation.driveFileId,
@ -47,6 +48,7 @@ export default providerRegistry.register({
token, token,
metadata.title, metadata.title,
[], [],
undefined,
html, html,
publishLocation.templateId ? 'text/html' : undefined, publishLocation.templateId ? 'text/html' : undefined,
publishLocation.driveFileId, publishLocation.driveFileId,

View File

@ -0,0 +1,256 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
import utils from '../utils';
let workspaceFolderId;
const makeWorkspaceId = () => {
};
export default providerRegistry.register({
id: 'googleDriveWorkspace',
getToken() {
return store.getters['data/loginToken'];
},
initWorkspace() {
const initFolder = (token, folder) => Promise.resolve({
workspaceId: this.makeWorkspaceId(folder.id),
dataFolderId: folder.appProperties.dataFolderId,
trashFolderId: folder.appProperties.trashFolderId,
})
.then((properties) => {
// Make sure data folder exists
if (properties.dataFolderId) {
return properties;
}
return googleHelper.uploadFile(
token,
'.stackedit-data',
[folder.id],
{ workspaceId: properties.workspaceId },
undefined,
'application/vnd.google-apps.folder',
)
.then(dataFolder => ({
...properties,
dataFolderId: dataFolder.id,
}));
})
.then((properties) => {
// Make sure trash folder exists
if (properties.trashFolderId) {
return properties;
}
return googleHelper.uploadFile(
token,
'.stackedit-trash',
[folder.id],
{ workspaceId: properties.workspaceId },
undefined,
'application/vnd.google-apps.folder',
)
.then(trashFolder => ({
...properties,
trashFolderId: trashFolder.id,
}));
})
.then((properties) => {
// Update workspace if some properties are missing
if (properties.workspaceId === folder.appProperties.workspaceId
&& properties.dataFolderId === folder.appProperties.dataFolderId
&& properties.trashFolderId === folder.appProperties.trashFolderId
) {
return properties;
}
return googleHelper.uploadFile(
token,
undefined,
undefined,
properties,
undefined,
'application/vnd.google-apps.folder',
folder.id,
)
.then(() => properties);
})
.then((properties) => {
// Update workspace in the store
store.dispatch('data/patchWorkspaces', {
[properties.workspaceId]: {
id: properties.workspaceId,
sub: token.sub,
name: folder.name,
providerId: this.id,
folderId: folder.id,
dataFolderId: properties.dataFolderId,
trashFolderId: properties.trashFolderId,
},
});
return store.getters['data/workspaces'][properties.workspaceId];
});
return Promise.resolve(store.getters['data/googleTokens'][utils.queryParams.sub])
.then(token => token || this.$store.dispatch('modal/workspaceGoogleRedirection', {
onResolve: () => googleHelper.addDriveAccount(),
}))
.then(token => Promise.resolve()
.then(() => utils.queryParams.folderId || googleHelper.uploadFile(
token,
'StackEdit workspace',
[],
undefined,
undefined,
'application/vnd.google-apps.folder',
).then(folder => initFolder(token, folder).then(() => folder.id)))
.then((folderId) => {
const workspaceId = this.makeWorkspaceId(folderId);
const workspace = store.getters['data/workspaces'][workspaceId];
return workspace || googleHelper.getFile(token, folderId)
.then((folder) => {
const folderWorkspaceId = folder.appProperties.workspaceId;
if (folderWorkspaceId && folderWorkspaceId !== workspaceId) {
throw new Error(`Google Drive folder ${folderId} is part of another workspace.`);
}
return initFolder(token, folder);
});
}));
},
getChanges(token) {
return googleHelper.getChanges(token)
.then((result) => {
const changes = result.changes.filter((change) => {
if (change.file) {
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
return false;
}
// Build sync data
change.syncData = {
id: change.fileId,
itemId: change.item.id,
type: change.item.type,
hash: change.item.hash,
};
change.file = undefined;
}
return true;
});
changes.nextPageToken = result.nextPageToken;
return changes;
});
},
setAppliedChanges(token, changes) {
const lastToken = store.getters['data/googleTokens'][token.sub];
if (changes.nextPageToken !== lastToken.nextPageToken) {
store.dispatch('data/setGoogleToken', {
...lastToken,
nextPageToken: changes.nextPageToken,
});
}
},
saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.uploadAppDataFile(
token,
JSON.stringify(item),
['appDataFolder'],
undefined,
undefined,
syncData && syncData.id,
ifNotTooLate,
)
.then(file => ({
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
}));
},
removeItem(token, syncData, ifNotTooLate) {
return googleHelper.removeAppDataFile(token, syncData.id, ifNotTooLate)
.then(() => syncData);
},
downloadContent(token, syncLocation) {
return this.downloadData(token, `${syncLocation.fileId}/content`);
},
downloadData(token, dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
return googleHelper.downloadAppDataFile(token, syncData.id)
.then((content) => {
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
},
});
}
return item;
});
},
uploadContent(token, content, syncLocation, ifNotTooLate) {
return this.uploadData(token, content, `${syncLocation.fileId}/content`, ifNotTooLate)
.then(() => syncLocation);
},
uploadData(token, item, dataId, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
return googleHelper.uploadAppDataFile(
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
['appDataFolder'],
undefined,
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
.then(file => store.dispatch('data/patchSyncData', {
[file.id]: {
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
}));
},
listRevisions(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.getFileRevisions(token, syncData.id)
.then(revisions => revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
created: new Date(revision.modifiedTime).getTime(),
})));
},
getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => JSON.parse(content));
},
makeWorkspaceId(folderId) {
return Math.abs(utils.hash(utils.serializeObject({
providerId: this.id,
folderId: folderId,
}))).toString(36);
},
});

View File

@ -53,7 +53,16 @@ export default {
throw err; throw err;
}); });
}, },
uploadFileInternal(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) { uploadFileInternal(
refreshedToken,
name,
parents,
appProperties,
media = null,
mediaType = null,
fileId = null,
ifNotTooLate = cb => res => cb(res),
) {
return Promise.resolve() return Promise.resolve()
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
.then(ifNotTooLate(() => { .then(ifNotTooLate(() => {
@ -61,7 +70,7 @@ export default {
method: 'POST', method: 'POST',
url: 'https://www.googleapis.com/drive/v3/files', url: 'https://www.googleapis.com/drive/v3/files',
}; };
const metadata = { name }; const metadata = { name, appProperties };
if (fileId) { if (fileId) {
options.method = 'PATCH'; options.method = 'PATCH';
options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`;
@ -78,7 +87,7 @@ export default {
multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata); multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter; multipartRequestBody += delimiter;
multipartRequestBody += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`; multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\r\n\r\n`;
multipartRequestBody += media; multipartRequestBody += media;
multipartRequestBody += closeDelimiter; multipartRequestBody += closeDelimiter;
options.url = options.url.replace( options.url = options.url.replace(
@ -95,6 +104,9 @@ export default {
body: multipartRequestBody, body: multipartRequestBody,
}).then(res => res.body); }).then(res => res.body);
} }
if (mediaType) {
metadata.mimeType = mediaType;
}
return this.request(refreshedToken, { return this.request(refreshedToken, {
...options, ...options,
body: metadata, body: metadata,
@ -170,7 +182,7 @@ export default {
.then(token => this.getUser(token.sub) .then(token => this.getUser(token.sub)
.catch((err) => { .catch((err) => {
if (err.status === 404) { if (err.status === 404) {
store.dispatch('notification/info', 'Please activate Google Plus to change your account name!'); store.dispatch('notification/info', 'Please activate Google Plus to change your account name and photo!');
} else { } else {
throw err; throw err;
} }
@ -311,15 +323,23 @@ export default {
return getPage(refreshedToken.nextPageToken); return getPage(refreshedToken.nextPageToken);
}); });
}, },
uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) { uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(token, getDriveScopes(token)) return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.uploadFileInternal( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate)); refreshedToken, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate));
}, },
uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { uploadAppDataFile(token, name, parents, appProperties, media, fileId, ifNotTooLate) {
return this.refreshToken(token, driveAppDataScopes) return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.uploadFileInternal( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate)); refreshedToken, name, parents, appProperties, media, undefined, fileId, ifNotTooLate));
},
getFile(token, id) {
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})
.then(res => res.body));
}, },
downloadFile(token, id) { downloadFile(token, id) {
return this.refreshToken(token, getDriveScopes(token)) return this.refreshToken(token, getDriveScopes(token))

View File

@ -63,7 +63,7 @@ export default {
openFileWithLocation(allLocations, criteria) { openFileWithLocation(allLocations, criteria) {
return allLocations.some((location) => { return allLocations.some((location) => {
// If every field fits the criteria // If every field fits the criteria
if (Object.keys(criteria).every(key => criteria[key] === location[key])) { if (Object.entries(criteria).every(([key, value]) => value === location[key])) {
// Found one location that fits, open it if it exists // Found one location that fits, open it if it exists
const file = store.state.file.itemMap[location.fileId]; const file = store.state.file.itemMap[location.fileId];
if (file) { if (file) {

View File

@ -3,38 +3,73 @@ import store from '../store';
import utils from './utils'; import utils from './utils';
import diffUtils from './diffUtils'; import diffUtils from './diffUtils';
import providerRegistry from './providers/providerRegistry'; import providerRegistry from './providers/providerRegistry';
import mainProvider from './providers/googleDriveAppDataProvider'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`;
let lastSyncActivity;
const getLastStoredSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
const inactivityThreshold = 3 * 1000; // 3 sec const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec const restartSyncAfter = 30 * 1000; // 30 sec
const autoSyncAfter = utils.randomize(60 * 1000); // 60 sec const autoSyncAfter = utils.randomize(60 * 1000); // 60 sec
const isDataSyncPossible = () => !!store.getters['data/loginToken']; let workspaceProvider;
/**
* Use a lock in the local storage to prevent multiple windows concurrency.
*/
const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`;
let lastSyncActivity;
const getLastStoredSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
/**
* Return true if workspace sync is possible.
*/
const isWorkspaceSyncPossible = () => {
const loginToken = store.getters['data/loginToken'];
if (!loginToken && Object.keys(store.getters['data/syncData']).length) {
// Reset sync data if token was removed
store.dispatch('data/setSyncData', {});
}
return !!loginToken;
};
/**
* Return true if file has at least one explicit sync location.
*/
const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length; const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length;
/**
* Return true if we are online and we have something to sync.
*/
const isSyncPossible = () => !store.state.offline && const isSyncPossible = () => !store.state.offline &&
(isDataSyncPossible() || hasCurrentFileSyncLocations()); (isWorkspaceSyncPossible() || hasCurrentFileSyncLocations());
/**
* Return true if we are the many window, ie we have the lastSyncActivity lock.
*/
function isSyncWindow() { function isSyncWindow() {
const storedLastSyncActivity = getLastStoredSyncActivity(); const storedLastSyncActivity = getLastStoredSyncActivity();
return lastSyncActivity === storedLastSyncActivity || return lastSyncActivity === storedLastSyncActivity ||
Date.now() > inactivityThreshold + storedLastSyncActivity; Date.now() > inactivityThreshold + storedLastSyncActivity;
} }
/**
* Return true if auto sync can start, ie that lastSyncActivity is old enough.
*/
function isAutoSyncReady() { function isAutoSyncReady() {
const storedLastSyncActivity = getLastStoredSyncActivity(); const storedLastSyncActivity = getLastStoredSyncActivity();
return Date.now() > autoSyncAfter + storedLastSyncActivity; return Date.now() > autoSyncAfter + storedLastSyncActivity;
} }
/**
* Update the lastSyncActivity, assuming we have the lock.
*/
function setLastSyncActivity() { function setLastSyncActivity() {
const currentDate = Date.now(); const currentDate = Date.now();
lastSyncActivity = currentDate; lastSyncActivity = currentDate;
localStorage[lastSyncActivityKey] = currentDate; localStorage[lastSyncActivityKey] = currentDate;
} }
/**
* Clean a syncedContent.
*/
function cleanSyncedContent(syncedContent) { function cleanSyncedContent(syncedContent) {
// Clean syncHistory from removed syncLocations // Clean syncHistory from removed syncLocations
Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => { Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {
@ -42,17 +77,21 @@ function cleanSyncedContent(syncedContent) {
delete syncedContent.syncHistory[syncLocationId]; delete syncedContent.syncHistory[syncLocationId];
} }
}); });
const allSyncLocationHashes = new Set([].concat( const allSyncLocationHashSet = new Set([].concat(
...Object.keys(syncedContent.syncHistory).map( ...Object.keys(syncedContent.syncHistory).map(
id => syncedContent.syncHistory[id]))); id => syncedContent.syncHistory[id])));
// Clean historyData from unused contents // Clean historyData from unused contents
Object.keys(syncedContent.historyData).map(hash => parseInt(hash, 10)).forEach((hash) => { Object.keys(syncedContent.historyData).map(hash => parseInt(hash, 10)).forEach((hash) => {
if (!allSyncLocationHashes.has(hash)) { if (!allSyncLocationHashSet.has(hash)) {
delete syncedContent.historyData[hash]; delete syncedContent.historyData[hash];
} }
}); });
} }
/**
* Apply changes retrieved from the main provider. Update sync data accordingly.
* @param {*} changes The changes to apply.
*/
function applyChanges(changes) { function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap }; const storeItemMap = { ...store.getters.allItemMap };
const syncData = { ...store.getters['data/syncData'] }; const syncData = { ...store.getters['data/syncData'] };
@ -92,6 +131,9 @@ function applyChanges(changes) {
const LAST_SENT = 0; const LAST_SENT = 0;
const LAST_MERGED = 1; const LAST_MERGED = 1;
/**
* Create a sync location by uploading the current file content.
*/
function createSyncLocation(syncLocation) { function createSyncLocation(syncLocation) {
syncLocation.id = utils.uid(); syncLocation.id = utils.uid();
const currentFile = store.getters['file/current']; const currentFile = store.getters['file/current'];
@ -137,6 +179,9 @@ class FileSyncContext {
} }
} }
/**
* Sync one file with all its locations.
*/
function syncFile(fileId, syncContext = new SyncContext()) { function syncFile(fileId, syncContext = new SyncContext()) {
const fileSyncContext = new FileSyncContext(); const fileSyncContext = new FileSyncContext();
syncContext.synced[`${fileId}/content`] = true; syncContext.synced[`${fileId}/content`] = true;
@ -174,7 +219,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
const syncLocations = [ const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/groupedByFileId'][fileId] || [],
]; ];
if (isDataSyncPossible()) { if (isWorkspaceSyncPossible()) {
syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId }); syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId });
} }
let result; let result;
@ -349,7 +394,9 @@ function syncFile(fileId, syncContext = new SyncContext()) {
}); });
} }
/**
* Sync a data item, typically settings and templates.
*/
function syncDataItem(dataId) { function syncDataItem(dataId) {
const item = store.state.data.itemMap[dataId]; const item = store.state.data.itemMap[dataId];
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][dataId];
@ -418,7 +465,10 @@ function syncDataItem(dataId) {
}); });
} }
function sync() { /**
* Sync the whole workspace with the main provider and the current file explicit locations.
*/
function syncWorkspace() {
const syncContext = new SyncContext(); const syncContext = new SyncContext();
const mainToken = store.getters['data/loginToken']; const mainToken = store.getters['data/loginToken'];
return mainProvider.getChanges(mainToken) return mainProvider.getChanges(mainToken)
@ -447,8 +497,7 @@ function sync() {
}; };
const syncDataByItemId = store.getters['data/syncDataByItemId']; const syncDataByItemId = store.getters['data/syncDataByItemId'];
let result; let result;
Object.keys(storeItemMap).some((id) => { Object.entries(storeItemMap).some(([id, item]) => {
const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id]; const existingSyncData = syncDataByItemId[id];
if ((!existingSyncData || existingSyncData.hash !== item.hash) && if ((!existingSyncData || existingSyncData.hash !== item.hash) &&
// Add file if content has been uploaded // Add file if content has been uploaded
@ -483,8 +532,7 @@ function sync() {
}; };
const syncData = store.getters['data/syncData']; const syncData = store.getters['data/syncData'];
let result; let result;
Object.keys(syncData).some((id) => { Object.entries(syncData).some(([, existingSyncData]) => {
const existingSyncData = syncData[id];
if (!storeItemMap[existingSyncData.itemId] && if (!storeItemMap[existingSyncData.itemId] &&
// Remove content only if file has been removed // Remove content only if file has been removed
(existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]]) (existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]])
@ -556,20 +604,23 @@ function sync() {
() => { () => {
if (syncContext.restart) { if (syncContext.restart) {
// Restart sync // Restart sync
return sync(); return syncWorkspace();
} }
return null; return null;
}, },
(err) => { (err) => {
if (err && err.message === 'TOO_LATE') { if (err && err.message === 'TOO_LATE') {
// Restart sync // Restart sync
return sync(); return syncWorkspace();
} }
throw err; throw err;
}); });
}); });
} }
/**
* Enqueue a sync task, if possible.
*/
function requestSync() { function requestSync() {
store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => { store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => {
let intervalId; let intervalId;
@ -607,8 +658,8 @@ function requestSync() {
Promise.resolve() Promise.resolve()
.then(() => { .then(() => {
if (isDataSyncPossible()) { if (isWorkspaceSyncPossible()) {
return sync(); return syncWorkspace();
} }
if (hasCurrentFileSyncLocations()) { if (hasCurrentFileSyncLocations()) {
// Only sync current file if data sync is unavailable. // Only sync current file if data sync is unavailable.
@ -620,9 +671,9 @@ function requestSync() {
}) })
.then(() => { .then(() => {
// Clean files // Clean files
Object.keys(fileHashesToClean).forEach((fileId) => { Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => {
const file = store.state.file.itemMap[fileId]; const file = store.state.file.itemMap[fileId];
if (file && file.hash === fileHashesToClean[fileId]) { if (file && file.hash === fileHash) {
store.dispatch('deleteFile', fileId); store.dispatch('deleteFile', fileId);
} }
}); });
@ -635,6 +686,21 @@ function requestSync() {
})); }));
} }
export default {
init() {
// Load workspaces and tokens from localStorage
localDbSvc.syncLocalStorage();
// Try to find a suitable workspace provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider;
}
return workspaceProvider.initWorkspace()
.then(workspace => store.commit('workspace/setCurrentWorkspaceId', workspace.id))
.then(() => localDbSvc.init())
.then(() => {
// Sync periodically // Sync periodically
utils.setInterval(() => { utils.setInterval(() => {
if (isSyncPossible() && if (isSyncPossible() &&
@ -653,8 +719,8 @@ utils.setInterval(() => {
localDbSvc.unloadContents(); localDbSvc.unloadContents();
} }
}, 5000); }, 5000);
});
export default { },
isSyncPossible, isSyncPossible,
requestSync, requestSync,
createSyncLocation, createSyncLocation,

View File

@ -31,7 +31,9 @@ const setLastFocus = () => {
localStorage[lastFocusKey] = lastFocus; localStorage[lastFocusKey] = lastFocus;
setLastActivity(); setLastActivity();
}; };
if (document.hasFocus()) {
setLastFocus(); setLastFocus();
}
window.addEventListener('focus', setLastFocus); window.addEventListener('focus', setLastFocus);
// For parseQueryParams() // For parseQueryParams()
@ -64,6 +66,12 @@ export default {
'publishLocation', 'publishLocation',
'data', 'data',
], ],
localStorageDataIds: [
'workspaces',
'settings',
'layoutSettings',
'tokens',
],
textMaxLength: 150000, textMaxLength: 150000,
sanitizeText(text) { sanitizeText(text) {
const result = `${text || ''}`.slice(0, this.textMaxLength); const result = `${text || ''}`.slice(0, this.textMaxLength);
@ -148,10 +156,10 @@ export default {
parseQueryParams, parseQueryParams,
addQueryParams(url = '', params = {}) { addQueryParams(url = '', params = {}) {
const keys = Object.keys(params).filter(key => params[key] != null); const keys = Object.keys(params).filter(key => params[key] != null);
if (!keys.length) {
return url;
}
urlParser.href = url; urlParser.href = url;
if (!keys.length) {
return urlParser.href;
}
if (urlParser.search) { if (urlParser.search) {
urlParser.search += '&'; urlParser.search += '&';
} else { } else {
@ -180,8 +188,8 @@ export default {
startOffset = 0; startOffset = 0;
} }
const elt = document.createElement('span'); const elt = document.createElement('span');
Object.keys(eltProperties).forEach((key) => { Object.entries(eltProperties).forEach(([key, value]) => {
elt[key] = eltProperties[key]; elt[key] = value;
}); });
treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode); treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode);
elt.appendChild(treeWalker.currentNode); elt.appendChild(treeWalker.currentNode);

View File

@ -81,8 +81,7 @@ module.actions = {
const diffs = diffMatchPatch.diff_main( const diffs = diffMatchPatch.diff_main(
currentContent.text, revisionContent.originalText); currentContent.text, revisionContent.originalText);
diffMatchPatch.diff_cleanupSemantic(diffs); diffMatchPatch.diff_cleanupSemantic(diffs);
Object.keys(currentContent.discussions).forEach((discussionId) => { Object.entries(currentContent.discussions).forEach(([, discussion]) => {
const discussion = currentContent.discussions[discussionId];
const adjustOffset = (offsetName) => { const adjustOffset = (offsetName) => {
const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end');
marker.adjustOffset(diffs); marker.adjustOffset(diffs);

View File

@ -1,9 +1,10 @@
import Vue from 'vue'; import Vue from 'vue';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import moduleTemplate from './moduleTemplate';
import utils from '../services/utils'; import utils from '../services/utils';
import defaultWorkspaces from '../data/defaultWorkspaces';
import defaultSettings from '../data/defaultSettings.yml'; import defaultSettings from '../data/defaultSettings.yml';
import defaultLocalSettings from '../data/defaultLocalSettings'; import defaultLocalSettings from '../data/defaultLocalSettings';
import defaultLayoutSettings from '../data/defaultLayoutSettings';
import plainHtmlTemplate from '../data/plainHtmlTemplate.html'; import plainHtmlTemplate from '../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../data/styledHtmlTemplate.html'; import styledHtmlTemplate from '../data/styledHtmlTemplate.html';
import styledHtmlWithTocTemplate from '../data/styledHtmlWithTocTemplate.html'; import styledHtmlWithTocTemplate from '../data/styledHtmlWithTocTemplate.html';
@ -13,36 +14,31 @@ const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
const empty = (id) => { const empty = (id) => {
switch (id) { switch (id) {
case 'workspaces':
return itemTemplate(id, defaultWorkspaces());
case 'settings': case 'settings':
return itemTemplate(id, '\n'); return itemTemplate(id, '\n');
case 'localSettings': case 'localSettings':
return itemTemplate(id, defaultLocalSettings()); return itemTemplate(id, defaultLocalSettings());
case 'layoutSettings':
return itemTemplate(id, defaultLayoutSettings());
default: default:
return itemTemplate(id); return itemTemplate(id);
} }
}; };
const module = moduleTemplate(empty, true);
module.mutations.setItem = (state, value) => { // Item IDs that will be stored in the localStorage
const emptyItem = empty(value.id); const lsItemIdSet = new Set(utils.localStorageDataIds);
const data = typeof value.data === 'object'
? Object.assign(emptyItem.data, value.data)
: value.data;
const item = {
...emptyItem,
data,
};
item.hash = utils.hash(utils.serializeObject({
...item,
hash: undefined,
}));
Vue.set(state.itemMap, item.id, item);
};
const getter = id => state => (state.itemMap[id] || empty(id)).data; // Getter/setter/patcher factories
const getter = id => state => ((lsItemIdSet.has(id)
? state.lsItemMap
: state.itemMap)[id] || empty(id)).data;
const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));
const patcher = id => ({ state, commit }, data) => { const patcher = id => ({ state, commit }, data) => {
const item = Object.assign(empty(id), state.itemMap[id]); const item = Object.assign(empty(id), (lsItemIdSet.has(id)
? state.lsItemMap
: state.itemMap)[id]);
commit('setItem', { commit('setItem', {
...empty(id), ...empty(id),
data: typeof data === 'object' ? { data: typeof data === 'object' ? {
@ -52,18 +48,10 @@ const patcher = id => ({ state, commit }, data) => {
}); });
}; };
// Local settings // For layoutSettings
module.getters.localSettings = getter('localSettings'); const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLayoutSettings', {
module.actions.patchLocalSettings = patcher('localSettings'); [propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value,
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
}); });
module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
module.actions.toggleEditor = localSettingsToggler('showEditor');
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
module.actions.toggleScrollSync = localSettingsToggler('scrollSync');
module.actions.toggleFocusMode = localSettingsToggler('focusMode');
const notEnoughSpace = (getters) => { const notEnoughSpace = (getters) => {
const constants = getters['layout/constants']; const constants = getters['layout/constants'];
const showGutter = getters['discussion/currentDiscussion']; const showGutter = getters['discussion/currentDiscussion'];
@ -73,35 +61,89 @@ const notEnoughSpace = (getters) => {
constants.buttonBarWidth + constants.buttonBarWidth +
(showGutter ? constants.gutterWidth : 0); (showGutter ? constants.gutterWidth : 0);
}; };
module.actions.toggleSideBar = ({ commit, getters, dispatch, rootGetters }, value) => {
// Reset side bar
dispatch('setSideBarPanel');
// Close explorer if not enough space
const patch = {
showSideBar: value === undefined ? !getters.localSettings.showSideBar : value,
};
if (patch.showSideBar && notEnoughSpace(rootGetters)) {
patch.showExplorer = false;
}
dispatch('patchLocalSettings', patch);
};
module.actions.toggleExplorer = ({ commit, getters, dispatch, rootGetters }, value) => {
// Close side bar if not enough space
const patch = {
showExplorer: value === undefined ? !getters.localSettings.showExplorer : value,
};
if (patch.showExplorer && notEnoughSpace(rootGetters)) {
patch.showSideBar = false;
}
dispatch('patchLocalSettings', patch);
};
module.actions.setSideBarPanel = ({ dispatch }, value) => dispatch('patchLocalSettings', {
sideBarPanel: value === undefined ? 'menu' : value,
});
// Settings // For templates
module.getters.settings = getter('settings'); const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
module.getters.computedSettings = (state, getters) => { name,
value,
helpers,
isAdditional: true,
});
const additionalTemplates = {
plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
};
// For tokens
const tokenSetter = providerId => ({ getters, dispatch }, token) => {
dispatch('patchTokens', {
[providerId]: {
...getters[`${providerId}Tokens`],
[token.sub]: token,
},
});
};
export default {
namespaced: true,
state: {
// Data items stored in the DB
itemMap: {},
// Data items stored in the localStorage
lsItemMap: {},
},
mutations: {
setItem: (state, value) => {
// Create an empty item and override its data field
const emptyItem = empty(value.id);
const data = typeof value.data === 'object'
? Object.assign(emptyItem.data, value.data)
: value.data;
const item = {
...emptyItem,
data,
};
// Calculate item hash
item.hash = utils.hash(utils.serializeObject({
...item,
hash: undefined,
}));
// Store item in itemMap or lsItemMap if its stored in the localStorage
Vue.set(lsItemIdSet.has(item.id) ? state.lsItemMap : state.itemMap, item.id, item);
},
},
getters: {
workspaces: (state) => {
const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data;
const result = {};
Object.entries(workspaces).forEach(([id, workspace]) => {
result[id] = {
...workspace,
id,
providerId: workspace.providerId || 'googleDriveWorkspace',
url: utils.addQueryParams('app'),
};
});
return result;
},
dbName: (state, getters) => {
let dbName;
Object.keys(getters.workspaces).some((id) => {
dbName = 'stackedit-db';
if (id !== 'main') {
dbName += `-${id}`;
}
return dbName;
});
return dbName;
},
settings: getter('settings'),
computedSettings: (state, getters) => {
const customSettings = yaml.safeLoad(getters.settings); const customSettings = yaml.safeLoad(getters.settings);
const settings = yaml.safeLoad(defaultSettings); const settings = yaml.safeLoad(defaultSettings);
const override = (obj, opt) => { const override = (obj, opt) => {
@ -122,42 +164,16 @@ module.getters.computedSettings = (state, getters) => {
return obj; return obj;
}; };
return override(settings, customSettings); return override(settings, customSettings);
}; },
module.actions.setSettings = setter('settings'); localSettings: getter('localSettings'),
layoutSettings: getter('layoutSettings'),
// Templates templates: getter('templates'),
module.getters.templates = getter('templates'); allTemplates: (state, getters) => ({
const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
name,
value,
helpers,
isAdditional: true,
});
const additionalTemplates = {
plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
};
module.getters.allTemplates = (state, getters) => ({
...getters.templates, ...getters.templates,
...additionalTemplates, ...additionalTemplates,
}); }),
module.actions.setTemplates = ({ commit }, data) => { lastOpened: getter('lastOpened'),
const dataToCommit = { lastOpenedIds: (state, getters, rootState) => {
...data,
};
// We don't store additional templates
Object.keys(additionalTemplates).forEach((id) => {
delete dataToCommit[id];
});
commit('setItem', itemTemplate('templates', dataToCommit));
};
// Last opened
module.getters.lastOpened = getter('lastOpened');
module.getters.lastOpenedIds = (state, getters, rootState) => {
const lastOpened = { const lastOpened = {
...getters.lastOpened, ...getters.lastOpened,
}; };
@ -169,83 +185,114 @@ module.getters.lastOpenedIds = (state, getters, rootState) => {
.filter(id => rootState.file.itemMap[id]) .filter(id => rootState.file.itemMap[id])
.sort((id1, id2) => lastOpened[id2] - lastOpened[id1]) .sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
.slice(0, 20); .slice(0, 20);
}; },
module.actions.setLastOpenedId = ({ getters, commit, dispatch, rootState }, fileId) => { syncData: getter('syncData'),
const lastOpened = { ...getters.lastOpened }; syncDataByItemId: (state, getters) => {
lastOpened[fileId] = Date.now();
commit('setItem', itemTemplate('lastOpened', lastOpened));
dispatch('cleanLastOpenedId');
};
module.actions.cleanLastOpenedId = ({ getters, commit, rootState }) => {
const lastOpened = {};
const oldLastOpened = getters.lastOpened;
Object.keys(oldLastOpened).forEach((fileId) => {
if (rootState.file.itemMap[fileId]) {
lastOpened[fileId] = oldLastOpened[fileId];
}
});
commit('setItem', itemTemplate('lastOpened', lastOpened));
};
// Sync data
module.getters.syncData = getter('syncData');
module.getters.syncDataByItemId = (state, getters) => {
const result = {}; const result = {};
const syncData = getters.syncData; Object.entries(getters.syncData).forEach(([, value]) => {
Object.keys(syncData).forEach((id) => {
const value = syncData[id];
result[value.itemId] = value; result[value.itemId] = value;
}); });
return result; return result;
}; },
module.getters.syncDataByType = (state, getters) => { syncDataByType: (state, getters) => {
const result = {}; const result = {};
utils.types.forEach((type) => { utils.types.forEach((type) => {
result[type] = {}; result[type] = {};
}); });
const syncData = getters.syncData; Object.entries(getters.syncData).forEach(([, item]) => {
Object.keys(syncData).forEach((id) => {
const item = syncData[id];
if (result[item.type]) { if (result[item.type]) {
result[item.type][item.itemId] = item; result[item.type][item.itemId] = item;
} }
}); });
return result; return result;
}; },
module.actions.patchSyncData = patcher('syncData'); dataSyncData: getter('dataSyncData'),
module.actions.setSyncData = setter('syncData'); tokens: getter('tokens'),
googleTokens: (state, getters) => getters.tokens.google || {},
// Data sync data (used to sync settings and settings) dropboxTokens: (state, getters) => getters.tokens.dropbox || {},
module.getters.dataSyncData = getter('dataSyncData'); githubTokens: (state, getters) => getters.tokens.github || {},
module.actions.patchDataSyncData = patcher('dataSyncData'); wordpressTokens: (state, getters) => getters.tokens.wordpress || {},
zendeskTokens: (state, getters) => getters.tokens.zendesk || {},
// Tokens loginToken: (state, getters) => {
module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
module.getters.dropboxTokens = (state, getters) => getters.tokens.dropbox || {};
module.getters.githubTokens = (state, getters) => getters.tokens.github || {};
module.getters.wordpressTokens = (state, getters) => getters.tokens.wordpress || {};
module.getters.zendeskTokens = (state, getters) => getters.tokens.zendesk || {};
module.getters.loginToken = (state, getters) => {
// Return the first google token that has the isLogin flag // Return the first google token that has the isLogin flag
const googleTokens = getters.googleTokens; const googleTokens = getters.googleTokens;
const loginSubs = Object.keys(googleTokens) const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin); .filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]]; return googleTokens[loginSubs[0]];
};
module.actions.patchTokens = patcher('tokens');
const tokenSetter = providerId => ({ getters, dispatch }, token) => {
dispatch('patchTokens', {
[providerId]: {
...getters[`${providerId}Tokens`],
[token.sub]: token,
}, },
}); },
}; actions: {
module.actions.setGoogleToken = tokenSetter('google'); setWorkspaces: setter('workspaces'),
module.actions.setDropboxToken = tokenSetter('dropbox'); patchWorkspaces: patcher('workspaces'),
module.actions.setGithubToken = tokenSetter('github'); setSettings: setter('settings'),
module.actions.setWordpressToken = tokenSetter('wordpress'); patchLocalSettings: patcher('localSettings'),
module.actions.setZendeskToken = tokenSetter('zendesk'); patchLayoutSettings: patcher('layoutSettings'),
toggleNavigationBar: layoutSettingsToggler('showNavigationBar'),
toggleEditor: layoutSettingsToggler('showEditor'),
toggleSidePreview: layoutSettingsToggler('showSidePreview'),
toggleStatusBar: layoutSettingsToggler('showStatusBar'),
toggleScrollSync: layoutSettingsToggler('scrollSync'),
toggleFocusMode: layoutSettingsToggler('focusMode'),
toggleSideBar: ({ commit, getters, dispatch, rootGetters }, value) => {
// Reset side bar
dispatch('setSideBarPanel');
export default module; // Close explorer if not enough space
const patch = {
showSideBar: value === undefined ? !getters.layoutSettings.showSideBar : value,
};
if (patch.showSideBar && notEnoughSpace(rootGetters)) {
patch.showExplorer = false;
}
dispatch('patchLayoutSettings', patch);
},
toggleExplorer: ({ commit, getters, dispatch, rootGetters }, value) => {
// Close side bar if not enough space
const patch = {
showExplorer: value === undefined ? !getters.layoutSettings.showExplorer : value,
};
if (patch.showExplorer && notEnoughSpace(rootGetters)) {
patch.showSideBar = false;
}
dispatch('patchLayoutSettings', patch);
},
setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', {
sideBarPanel: value === undefined ? 'menu' : value,
}),
setTemplates: ({ commit }, data) => {
const dataToCommit = {
...data,
};
// We don't store additional templates
Object.keys(additionalTemplates).forEach((id) => {
delete dataToCommit[id];
});
commit('setItem', itemTemplate('templates', dataToCommit));
},
setLastOpenedId: ({ getters, commit, dispatch, rootState }, fileId) => {
const lastOpened = { ...getters.lastOpened };
lastOpened[fileId] = Date.now();
commit('setItem', itemTemplate('lastOpened', lastOpened));
dispatch('cleanLastOpenedId');
},
cleanLastOpenedId: ({ getters, commit, rootState }) => {
const lastOpened = {};
const oldLastOpened = getters.lastOpened;
Object.entries(oldLastOpened).forEach(([fileId, date]) => {
if (rootState.file.itemMap[fileId]) {
lastOpened[fileId] = date;
}
});
commit('setItem', itemTemplate('lastOpened', lastOpened));
},
setSyncData: setter('syncData'),
patchSyncData: patcher('syncData'),
patchDataSyncData: patcher('dataSyncData'),
patchTokens: patcher('tokens'),
setGoogleToken: tokenSetter('google'),
setDropboxToken: tokenSetter('dropbox'),
setGithubToken: tokenSetter('github'),
setWordpressToken: tokenSetter('wordpress'),
setZendeskToken: tokenSetter('zendesk'),
},
};

View File

@ -65,8 +65,7 @@ export default {
const discussions = rootGetters['content/current'].discussions; const discussions = rootGetters['content/current'].discussions;
const comments = rootGetters['content/current'].comments; const comments = rootGetters['content/current'].comments;
const discussionLastComments = {}; const discussionLastComments = {};
Object.keys(comments).forEach((commentId) => { Object.entries(comments).forEach(([, comment]) => {
const comment = comments[commentId];
if (discussions[comment.discussionId]) { if (discussions[comment.discussionId]) {
const lastComment = discussionLastComments[comment.discussionId]; const lastComment = discussionLastComments[comment.discussionId];
if (!lastComment || lastComment.created < comment.created) { if (!lastComment || lastComment.created < comment.created) {
@ -84,10 +83,10 @@ export default {
} }
const discussions = rootGetters['content/current'].discussions; const discussions = rootGetters['content/current'].discussions;
const discussionLastComments = getters.currentFileDiscussionLastComments; const discussionLastComments = getters.currentFileDiscussionLastComments;
Object.keys(discussionLastComments) Object.entries(discussionLastComments)
.sort((id1, id2) => .sort(([, lastComment1], [, lastComment2]) =>
discussionLastComments[id2].created - discussionLastComments[id1].created) lastComment1.created - lastComment2.created)
.forEach((discussionId) => { .forEach(([discussionId]) => {
currentFileDiscussions[discussionId] = discussions[discussionId]; currentFileDiscussions[discussionId] = discussions[discussionId];
}); });
return currentFileDiscussions; return currentFileDiscussions;
@ -100,13 +99,13 @@ export default {
const comments = {}; const comments = {};
if (getters.currentDiscussion) { if (getters.currentDiscussion) {
const contentComments = rootGetters['content/current'].comments; const contentComments = rootGetters['content/current'].comments;
Object.keys(contentComments) Object.entries(contentComments)
.filter(commentId => .filter(([, comment]) =>
contentComments[commentId].discussionId === state.currentDiscussionId) comment.discussionId === state.currentDiscussionId)
.sort((id1, id2) => .sort(([, comment1], [, comment2]) =>
contentComments[id1].created - contentComments[id2].created) comment1.created - comment2.created)
.forEach((commentId) => { .forEach(([commentId, comment]) => {
comments[commentId] = contentComments[commentId]; comments[commentId] = comment;
}); });
} }
return comments; return comments;
@ -126,10 +125,11 @@ export default {
createNewDiscussion({ commit, dispatch, rootGetters }, selection) { createNewDiscussion({ commit, dispatch, rootGetters }, selection) {
const loginToken = rootGetters['data/loginToken']; const loginToken = rootGetters['data/loginToken'];
if (!loginToken) { if (!loginToken) {
dispatch('modal/signInForComment', null, { root: true }) dispatch('modal/signInForComment', {
.then(() => googleHelper.signin()) onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()) .then(() => syncSvc.requestSync())
.then(() => dispatch('createNewDiscussion', selection)) .then(() => dispatch('createNewDiscussion', selection)),
}, { root: true })
.catch(() => { }); // Cancel .catch(() => { }); // Cancel
} else if (selection) { } else if (selection) {
let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim();
@ -150,8 +150,7 @@ export default {
discussions: {}, discussions: {},
comments: {}, comments: {},
}; };
Object.keys(comments).forEach((commentId) => { Object.entries(comments).forEach(([commentId, comment]) => {
const comment = comments[commentId];
const discussion = discussions[comment.discussionId]; const discussion = discussions[comment.discussionId];
if (discussion && comment !== filterComment && discussion !== filterDiscussion) { if (discussion && comment !== filterComment && discussion !== filterDiscussion) {
patch.discussions[comment.discussionId] = discussion; patch.discussions[comment.discussionId] = discussion;

View File

@ -87,8 +87,7 @@ export default {
nodeMap[item.id] = new Node(item, locations); nodeMap[item.id] = new Node(item, locations);
}); });
const rootNode = new Node(emptyFolder(), [], true, true); const rootNode = new Node(emptyFolder(), [], true, true);
Object.keys(nodeMap).forEach((id) => { Object.entries(nodeMap).forEach(([id, node]) => {
const node = nodeMap[id];
let parentNode = nodeMap[node.item.parentId]; let parentNode = nodeMap[node.item.parentId];
if (!parentNode || !parentNode.isFolder) { if (!parentNode || !parentNode.isFolder) {
if (id === 'trash') { if (id === 'trash') {

View File

@ -43,7 +43,6 @@ const store = new Vuex.Store({
userInfo, userInfo,
}, },
state: { state: {
ready: false,
offline: false, offline: false,
lastOfflineCheck: 0, lastOfflineCheck: 0,
minuteCounter: 0, minuteCounter: 0,
@ -61,9 +60,6 @@ const store = new Vuex.Store({
}, },
}, },
mutations: { mutations: {
setReady: (state) => {
state.ready = true;
},
setOffline: (state, value) => { setOffline: (state, value) => {
state.offline = value; state.offline = value;
}, },

View File

@ -20,14 +20,14 @@ const constants = {
statusBarHeight: 20, statusBarHeight: 20,
}; };
function computeStyles(state, getters, localSettings = getters['data/localSettings'], styles = { function computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = {
showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar, showNavigationBar: !layoutSettings.showEditor || layoutSettings.showNavigationBar,
showStatusBar: localSettings.showStatusBar, showStatusBar: layoutSettings.showStatusBar,
showEditor: localSettings.showEditor, showEditor: layoutSettings.showEditor,
showSidePreview: localSettings.showSidePreview && localSettings.showEditor, showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor,
showPreview: localSettings.showSidePreview || !localSettings.showEditor, showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor,
showSideBar: localSettings.showSideBar, showSideBar: layoutSettings.showSideBar,
showExplorer: localSettings.showExplorer, showExplorer: layoutSettings.showExplorer,
layoutOverflow: false, layoutOverflow: false,
}) { }) {
styles.innerHeight = state.layout.bodyHeight; styles.innerHeight = state.layout.bodyHeight;
@ -64,7 +64,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
styles.showSidePreview = false; styles.showSidePreview = false;
styles.showPreview = false; styles.showPreview = false;
styles.layoutOverflow = false; styles.layoutOverflow = false;
return computeStyles(state, getters, localSettings, styles); return computeStyles(state, getters, layoutSettings, styles);
} }
const computedSettings = getters['data/computedSettings']; const computedSettings = getters['data/computedSettings'];
@ -96,7 +96,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
if (!styles.showSidePreview) { if (!styles.showSidePreview) {
styles.previewWidth += constants.buttonBarWidth; styles.previewWidth += constants.buttonBarWidth;
} }
styles.previewGutterWidth = showGutter && !localSettings.showEditor styles.previewGutterWidth = showGutter && !layoutSettings.showEditor
? constants.gutterWidth ? constants.gutterWidth
: 0; : 0;
const previewLeftPadding = previewRightPadding + styles.previewGutterWidth; const previewLeftPadding = previewRightPadding + styles.previewGutterWidth;
@ -107,7 +107,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
doublePanelWidth; doublePanelWidth;
const editorRightPadding = Math.max( const editorRightPadding = Math.max(
Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);
styles.editorGutterWidth = showGutter && localSettings.showEditor styles.editorGutterWidth = showGutter && layoutSettings.showEditor
? constants.gutterWidth ? constants.gutterWidth
: 0; : 0;
const editorLeftPadding = editorRightPadding + styles.editorGutterWidth; const editorLeftPadding = editorRightPadding + styles.editorGutterWidth;
@ -129,8 +129,8 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
styles.hideLocations = true; styles.hideLocations = true;
} }
} }
styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth); styles.titleMaxWidth = Math.max(minTitleMaxWidth,
styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth); Math.min(maxTitleMaxWidth, styles.titleMaxWidth));
return styles; return styles;
} }
@ -162,8 +162,8 @@ export default {
updateBodySize({ commit, dispatch, rootGetters }) { updateBodySize({ commit, dispatch, rootGetters }) {
commit('updateBodySize'); commit('updateBodySize');
// Make sure both explorer and side bar are not open if body width is small // Make sure both explorer and side bar are not open if body width is small
const localSettings = rootGetters['data/localSettings']; const layoutSettings = rootGetters['data/layoutSettings'];
dispatch('data/toggleExplorer', localSettings.showExplorer, { root: true }); dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true });
}, },
}, },
}; };

View File

@ -86,24 +86,33 @@ export default {
rejectText: 'Cancel', rejectText: 'Cancel',
onResolve, onResolve,
}), }),
signInForSponsorship: ({ dispatch }) => dispatch('open', { workspaceGoogleRedirection: ({ dispatch }, { onResolve }) => dispatch('open', {
content: '<p>You have to sign in with Google to access this workspace.</p>',
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
onResolve,
}),
signInForSponsorship: ({ dispatch }, { onResolve }) => dispatch('open', {
type: 'signInForSponsorship', type: 'signInForSponsorship',
content: `<p>You have to sign in with Google to enable your sponsorship.</p> content: `<p>You have to sign in with Google to enable your sponsorship.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`, <div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in', resolveText: 'Ok, sign in',
rejectText: 'Cancel', rejectText: 'Cancel',
onResolve,
}), }),
signInForComment: ({ dispatch }) => dispatch('open', { signInForComment: ({ dispatch }, { onResolve }) => dispatch('open', {
content: `<p>You have to sign in with Google to start commenting.</p> content: `<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`, <div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in', resolveText: 'Ok, sign in',
rejectText: 'Cancel', rejectText: 'Cancel',
onResolve,
}), }),
signInForHistory: ({ dispatch }) => dispatch('open', { signInForHistory: ({ dispatch }, { onResolve }) => dispatch('open', {
content: `<p>You have to sign in with Google to enable revision history.</p> content: `<p>You have to sign in with Google to enable revision history.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`, <div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
resolveText: 'Ok, sign in', resolveText: 'Ok, sign in',
rejectText: 'Cancel', rejectText: 'Cancel',
onResolve,
}), }),
sponsorOnly: ({ dispatch }) => dispatch('open', { sponsorOnly: ({ dispatch }) => dispatch('open', {
content: '<p>This feature is restricted to <b>sponsor users</b> as it relies on server resources.</p>', content: '<p>This feature is restricted to <b>sponsor users</b> as it relies on server resources.</p>',

View File

@ -33,7 +33,7 @@ export default (empty, simpleHash = false) => {
itemMap: {}, itemMap: {},
}, },
getters: { getters: {
items: state => Object.keys(state.itemMap).map(key => state.itemMap[key]), items: state => Object.entries(state.itemMap).map(([, item]) => item),
}, },
mutations: { mutations: {
setItem, setItem,

29
src/store/workspace.js Normal file
View File

@ -0,0 +1,29 @@
import utils from '../services/utils';
import googleHelper from '../services/providers/helpers/googleHelper';
import syncSvc from '../services/syncSvc';
const idShifter = offset => (state, getters) => {
const ids = Object.keys(getters.currentFileDiscussions);
const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length;
return ids[idx % ids.length];
};
export default {
namespaced: true,
state: {
currentWorkspaceId: null,
},
mutations: {
setCurrentWorkspaceId: (state, value) => {
state.currentWorkspaceId = value;
},
},
getters: {
currentWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
return workspaces[state.currentWorkspaceId] || workspaces.main;
},
},
actions: {
},
};