Workspaces (part 1)
This commit is contained in:
parent
3a08bc617e
commit
9596339684
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<splash-screen v-if="!ready"></splash-screen>
|
||||
<div v-else class="app">
|
||||
<layout></layout>
|
||||
<div class="app">
|
||||
<splash-screen v-if="!ready"></splash-screen>
|
||||
<layout v-else></layout>
|
||||
<modal v-if="showModal"></modal>
|
||||
<notification></notification>
|
||||
</div>
|
||||
@ -9,11 +9,11 @@
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import Layout from './Layout';
|
||||
import Modal from './Modal';
|
||||
import Notification from './Notification';
|
||||
import SplashScreen from './SplashScreen';
|
||||
import syncSvc from '../services/syncSvc';
|
||||
import timeSvc from '../services/timeSvc';
|
||||
import store from '../store';
|
||||
|
||||
@ -66,14 +66,20 @@ export default {
|
||||
Notification,
|
||||
SplashScreen,
|
||||
},
|
||||
data: () => ({
|
||||
ready: false,
|
||||
}),
|
||||
computed: {
|
||||
...mapState([
|
||||
'ready',
|
||||
]),
|
||||
showModal() {
|
||||
return !!this.$store.getters['modal/config'];
|
||||
},
|
||||
},
|
||||
created() {
|
||||
syncSvc.init()
|
||||
.then(() => {
|
||||
this.ready = true;
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="button-bar">
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
<button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'">
|
||||
@ -12,13 +12,13 @@
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -30,7 +30,7 @@ import { mapGetters, mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
computed: mapGetters('data', [
|
||||
'localSettings',
|
||||
'layoutSettings',
|
||||
]),
|
||||
methods: mapActions('data', [
|
||||
'toggleNavigationBar',
|
||||
|
@ -84,7 +84,10 @@ export default {
|
||||
if (node.isFolder) {
|
||||
this.$store.commit('explorer/toggleOpenNode', id);
|
||||
} else {
|
||||
this.$store.commit('file/setCurrentId', id);
|
||||
// Prevent from freezing the UI while loading the file
|
||||
setTimeout(() => {
|
||||
this.$store.commit('file/setCurrentId', id);
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -47,12 +47,12 @@ const accessor = (fieldName, setterName) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const computedLocalSetting = key => ({
|
||||
const computedLayoutSetting = key => ({
|
||||
get() {
|
||||
return store.getters['data/localSettings'][key];
|
||||
return store.getters['data/layoutSettings'][key];
|
||||
},
|
||||
set(value) {
|
||||
store.dispatch('data/patchLocalSettings', {
|
||||
store.dispatch('data/patchLayoutSettings', {
|
||||
[key]: value,
|
||||
});
|
||||
},
|
||||
@ -95,14 +95,13 @@ export default {
|
||||
]),
|
||||
findText: accessor('findText', 'setFindText'),
|
||||
replaceText: accessor('replaceText', 'setReplaceText'),
|
||||
findCaseSensitive: computedLocalSetting('findCaseSensitive'),
|
||||
findUseRegexp: computedLocalSetting('findUseRegexp'),
|
||||
findCaseSensitive: computedLayoutSetting('findCaseSensitive'),
|
||||
findUseRegexp: computedLayoutSetting('findUseRegexp'),
|
||||
},
|
||||
methods: {
|
||||
highlightOccurrences() {
|
||||
const oldClassAppliers = {};
|
||||
Object.keys(this.classAppliers).forEach((key) => {
|
||||
const classApplier = this.classAppliers[key];
|
||||
Object.entries(this.classAppliers).forEach(([, classApplier]) => {
|
||||
const newKey = `${classApplier.startMarker.offset}:${classApplier.endMarker.offset}`;
|
||||
oldClassAppliers[newKey] = classApplier;
|
||||
});
|
||||
@ -137,8 +136,7 @@ export default {
|
||||
this.state = 'created';
|
||||
}
|
||||
}
|
||||
Object.keys(oldClassAppliers).forEach((key) => {
|
||||
const classApplier = oldClassAppliers[key];
|
||||
Object.entries(oldClassAppliers).forEach(([key, classApplier]) => {
|
||||
if (!this.classAppliers[key]) {
|
||||
classApplier.clean();
|
||||
if (classApplier === this.selectedClassApplier) {
|
||||
|
@ -11,10 +11,12 @@
|
||||
<image-modal v-else-if="config.type === 'image'"></image-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>
|
||||
<workspace-management-modal v-else-if="config.type === 'workspaceManagement'"></workspace-management-modal>
|
||||
<sponsor-modal v-else-if="config.type === 'sponsor'"></sponsor-modal>
|
||||
<!-- Providers -->
|
||||
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
|
||||
<google-drive-save-modal v-else-if="config.type === 'googleDriveSave'"></google-drive-save-modal>
|
||||
<google-drive-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>
|
||||
<dropbox-account-modal v-else-if="config.type === 'dropboxAccount'"></dropbox-account-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 SyncManagementModal from './modals/SyncManagementModal';
|
||||
import PublishManagementModal from './modals/PublishManagementModal';
|
||||
import WorkspaceManagementModal from './modals/WorkspaceManagementModal';
|
||||
import SponsorModal from './modals/SponsorModal';
|
||||
|
||||
// Providers
|
||||
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
|
||||
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
|
||||
import GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';
|
||||
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
|
||||
import DropboxAccountModal from './modals/providers/DropboxAccountModal';
|
||||
import DropboxSaveModal from './modals/providers/DropboxSaveModal';
|
||||
@ -94,10 +98,12 @@ export default {
|
||||
ImageModal,
|
||||
SyncManagementModal,
|
||||
PublishManagementModal,
|
||||
WorkspaceManagementModal,
|
||||
SponsorModal,
|
||||
// Providers
|
||||
GooglePhotoModal,
|
||||
GoogleDriveSaveModal,
|
||||
GoogleDriveWorkspaceModal,
|
||||
GoogleDrivePublishModal,
|
||||
DropboxAccountModal,
|
||||
DropboxSaveModal,
|
||||
@ -178,6 +184,10 @@ export default {
|
||||
height: 100%;
|
||||
background-color: rgba(160, 160, 160, 0.5);
|
||||
overflow: auto;
|
||||
|
||||
hr {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal__inner-1 {
|
||||
|
@ -219,6 +219,9 @@ export default {
|
||||
|
||||
.navigation-bar__inner--right {
|
||||
float: right;
|
||||
|
||||
/* prevent from seeing wrapped buttons */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.navigation-bar__inner--button {
|
||||
|
@ -13,6 +13,7 @@
|
||||
</div>
|
||||
<div class="side-bar__inner">
|
||||
<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>
|
||||
<publish-menu v-else-if="panel === 'publish'"></publish-menu>
|
||||
<history-menu v-else-if="panel === 'history'"></history-menu>
|
||||
@ -33,6 +34,7 @@
|
||||
import { mapActions } from 'vuex';
|
||||
import Toc from './Toc';
|
||||
import MainMenu from './menus/MainMenu';
|
||||
import WorkspacesMenu from './menus/WorkspacesMenu';
|
||||
import SyncMenu from './menus/SyncMenu';
|
||||
import PublishMenu from './menus/PublishMenu';
|
||||
import HistoryMenu from './menus/HistoryMenu';
|
||||
@ -43,6 +45,7 @@ import markdownConversionSvc from '../services/markdownConversionSvc';
|
||||
|
||||
const panelNames = {
|
||||
menu: 'Menu',
|
||||
workspaces: 'Workspaces',
|
||||
help: 'Markdown cheat sheet',
|
||||
toc: 'Table of contents',
|
||||
sync: 'Synchronize',
|
||||
@ -56,6 +59,7 @@ export default {
|
||||
components: {
|
||||
Toc,
|
||||
MainMenu,
|
||||
WorkspacesMenu,
|
||||
SyncMenu,
|
||||
PublishMenu,
|
||||
HistoryMenu,
|
||||
@ -67,7 +71,7 @@ export default {
|
||||
}),
|
||||
computed: {
|
||||
panel() {
|
||||
return this.$store.getters['data/localSettings'].sideBarPanel;
|
||||
return this.$store.getters['data/layoutSettings'].sideBarPanel;
|
||||
},
|
||||
panelName() {
|
||||
return panelNames[this.panel];
|
||||
|
@ -63,13 +63,13 @@ export default {
|
||||
'setCurrentDiscussionId',
|
||||
]),
|
||||
updateTops() {
|
||||
const localSettings = this.$store.getters['data/localSettings'];
|
||||
const layoutSettings = this.$store.getters['data/layoutSettings'];
|
||||
const minTop = -2;
|
||||
let minCommentTop = minTop;
|
||||
const getTop = (discussion, commentElt1, commentElt2, isCurrent) => {
|
||||
const firstElt = commentElt1 || commentElt2;
|
||||
const secondElt = commentElt1 && commentElt2;
|
||||
const coordinates = localSettings.showEditor
|
||||
const coordinates = layoutSettings.showEditor
|
||||
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
|
||||
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
|
||||
let commentTop = minTop;
|
||||
@ -98,10 +98,9 @@ export default {
|
||||
// Get the discussion top coordinates
|
||||
const tops = {};
|
||||
const discussions = this.currentFileDiscussions;
|
||||
Object.keys(discussions)
|
||||
.sort((id1, id2) => discussions[id1].end - discussions[id2].end)
|
||||
.forEach((discussionId) => {
|
||||
const discussion = this.currentFileDiscussions[discussionId];
|
||||
Object.entries(discussions)
|
||||
.sort(([, discussion1], [, discussion2]) => discussion1.end - discussion2.end)
|
||||
.forEach(([discussionId, discussion]) => {
|
||||
if (discussion === this.currentDiscussion || discussion === this.newDiscussion) {
|
||||
tops.current = getTop(
|
||||
discussion,
|
||||
@ -123,8 +122,8 @@ export default {
|
||||
() => this.updateTops(),
|
||||
{ immediate: true });
|
||||
|
||||
const localSettings = this.$store.getters['data/localSettings'];
|
||||
this.scrollerElt = localSettings.showEditor
|
||||
const layoutSettings = this.$store.getters['data/layoutSettings'];
|
||||
this.scrollerElt = layoutSettings.showEditor
|
||||
? editorSvc.editorElt.parentNode
|
||||
: editorSvc.previewElt.parentNode;
|
||||
|
||||
|
@ -68,15 +68,15 @@ export default {
|
||||
]),
|
||||
goToDiscussion(discussionId = this.currentDiscussionId) {
|
||||
this.setCurrentDiscussionId(discussionId);
|
||||
const localSettings = this.$store.getters['data/localSettings'];
|
||||
const layoutSettings = this.$store.getters['data/layoutSettings'];
|
||||
const discussion = this.currentFileDiscussions[discussionId];
|
||||
const coordinates = localSettings.showEditor
|
||||
const coordinates = layoutSettings.showEditor
|
||||
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
|
||||
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
|
||||
if (!coordinates) {
|
||||
this.$store.dispatch('notification/info', "Discussion can't be located in the file.");
|
||||
} else {
|
||||
const scrollerElt = localSettings.showEditor
|
||||
const scrollerElt = layoutSettings.showEditor
|
||||
? editorSvc.editorElt.parentNode
|
||||
: editorSvc.previewElt.parentNode;
|
||||
let scrollTop = coordinates.top - (scrollerElt.offsetHeight / 2);
|
||||
|
@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div class="side-bar__panel side-bar__panel--history">
|
||||
<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__icon">
|
||||
<user-image :user-id="revision.sub"></user-image>
|
||||
</div>
|
||||
<div class="revision__header flex flex--column">
|
||||
<user-name :user-id="revision.sub"></user-name>
|
||||
<div class="revision__created">{{revision.created | formatTime}}</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="history side-bar__panel">
|
||||
<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">
|
||||
<user-image :user-id="revision.sub"></user-image>
|
||||
</div>
|
||||
<div class="revision__header flex flex--column">
|
||||
<user-name :user-id="revision.sub"></user-name>
|
||||
<div class="revision__created">{{revision.created | formatTime}}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="history__spacer history__spacer--last"></div>
|
||||
<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>
|
||||
</template>
|
||||
@ -33,6 +37,7 @@ let cachedFileId;
|
||||
let revisionsPromise;
|
||||
let revisionContentPromises;
|
||||
const pageSize = 50;
|
||||
const spacerThreshold = 12 * 60 * 60 * 1000; // 12h
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -46,7 +51,15 @@ export default {
|
||||
}),
|
||||
computed: {
|
||||
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() {
|
||||
return this.showCount < this.allRevisions.length;
|
||||
@ -171,11 +184,34 @@ export default {
|
||||
<style lang="scss">
|
||||
@import '../common/variables.scss';
|
||||
|
||||
.side-bar__panel--history {
|
||||
.history {
|
||||
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;
|
||||
padding: 15px;
|
||||
height: auto;
|
||||
@ -199,7 +235,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child::before {
|
||||
.revision:first-child &::before {
|
||||
height: 67%;
|
||||
top: 33%;
|
||||
}
|
||||
@ -225,11 +261,6 @@ export default {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.revision__button {
|
||||
font-size: 14px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.layout--revision {
|
||||
.cledit-section *,
|
||||
.cl-preview-section * {
|
||||
|
@ -11,6 +11,11 @@
|
||||
</div>
|
||||
<span>Signed in as <b>{{loginToken.name}}</b>.</span>
|
||||
</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>
|
||||
<menu-entry @click.native="setPanel('sync')">
|
||||
<icon-sync slot="icon"></icon-sync>
|
||||
@ -22,17 +27,16 @@
|
||||
<div>Publish</div>
|
||||
<span>Export your file to the web.</span>
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="fileProperties">
|
||||
<icon-view-list slot="icon"></icon-view-list>
|
||||
<div>File properties</div>
|
||||
<span>Add metadata and configure extensions.</span>
|
||||
</menu-entry>
|
||||
<menu-entry @click.native="history">
|
||||
<icon-history slot="icon"></icon-history>
|
||||
<div>File history</div>
|
||||
<span>Track and restore file revisions.</span>
|
||||
</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>
|
||||
<menu-entry @click.native="setPanel('toc')">
|
||||
<icon-toc slot="icon"></icon-toc>
|
||||
@ -42,7 +46,6 @@
|
||||
<icon-help-circle slot="icon"></icon-help-circle>
|
||||
Markdown cheat sheet
|
||||
</menu-entry>
|
||||
<hr>
|
||||
<menu-entry @click.native="print">
|
||||
<icon-printer slot="icon"></icon-printer>
|
||||
Print
|
||||
@ -122,9 +125,10 @@ export default {
|
||||
history() {
|
||||
const loginToken = this.$store.getters['data/loginToken'];
|
||||
if (!loginToken) {
|
||||
this.$store.dispatch('modal/signInForHistory')
|
||||
.then(() => googleHelper.signin())
|
||||
.then(() => syncSvc.requestSync())
|
||||
this.$store.dispatch('modal/signInForHistory', {
|
||||
onResolve: () => googleHelper.signin()
|
||||
.then(() => syncSvc.requestSync()),
|
||||
})
|
||||
.catch(() => { }); // Cancel
|
||||
} else {
|
||||
this.setPanel('history');
|
||||
|
60
src/components/menus/WorkspacesMenu.vue
Normal file
60
src/components/menus/WorkspacesMenu.vue
Normal 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>
|
@ -24,7 +24,7 @@
|
||||
text-decoration: underline;
|
||||
text-decoration-skip: ink;
|
||||
|
||||
&.menu-entry__sponsor {
|
||||
.menu-entry__sponsor {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
@ -37,10 +37,9 @@ export default modalTemplate({
|
||||
computed: {
|
||||
googlePhotosTokens() {
|
||||
const googleToken = this.$store.getters['data/googleTokens'];
|
||||
return Object.keys(googleToken)
|
||||
.map(sub => googleToken[sub])
|
||||
.filter(token => token.isPhotos)
|
||||
.sort((token1, token2) => token1.name.localeCompare(token2.name));
|
||||
return Object.entries(googleToken)
|
||||
.filter(([, token]) => token.isPhotos)
|
||||
.sort(([, token1], [, token2]) => token1.name.localeCompare(token2.name));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -4,7 +4,7 @@
|
||||
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
|
||||
<p v-else><b>{{currentFileName}}</b> is not published yet.</p>
|
||||
<div>
|
||||
<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">
|
||||
<icon-provider :provider-id="location.providerId"></icon-provider>
|
||||
</div>
|
||||
@ -26,8 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">Cancel</button>
|
||||
<button class="button" @click="config.resolve()">Ok</button>
|
||||
<button class="button" @click="config.resolve()">Close</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
|
||||
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
|
||||
<div>
|
||||
<div 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">
|
||||
<icon-provider :provider-id="location.providerId"></icon-provider>
|
||||
</div>
|
||||
@ -26,8 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__button-bar">
|
||||
<button class="button" @click="config.reject()">Cancel</button>
|
||||
<button class="button" @click="config.resolve()">Ok</button>
|
||||
<button class="button" @click="config.resolve()">Close</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</template>
|
||||
|
@ -95,12 +95,12 @@ export default {
|
||||
(allTemplates) => {
|
||||
const templates = {};
|
||||
// Sort templates by name
|
||||
Object.keys(allTemplates)
|
||||
.sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name))
|
||||
.forEach((id) => {
|
||||
const template = utils.deepCopy(allTemplates[id]);
|
||||
fillEmptyFields(template);
|
||||
templates[id] = template;
|
||||
Object.entries(allTemplates)
|
||||
.sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
|
||||
.forEach(([id, template]) => {
|
||||
const templateClone = utils.deepCopy(template);
|
||||
fillEmptyFields(templateClone);
|
||||
templates[id] = templateClone;
|
||||
});
|
||||
this.templates = templates;
|
||||
this.selectedId = this.config.selectedId;
|
||||
|
133
src/components/modals/WorkspaceManagementModal.vue
Normal file
133
src/components/modals/WorkspaceManagementModal.vue
Normal 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>
|
@ -32,9 +32,11 @@ export default {
|
||||
sponsor() {
|
||||
Promise.resolve()
|
||||
.then(() => !this.$store.getters['data/loginToken'] &&
|
||||
this.$store.dispatch('modal/signInForSponsorship') // If user has to sign in
|
||||
.then(() => googleHelper.signin())
|
||||
.then(() => syncSvc.requestSync()))
|
||||
// If user has to sign in
|
||||
this.$store.dispatch('modal/signInForSponsorship', {
|
||||
onResolve: () => googleHelper.signin()
|
||||
.then(() => syncSvc.requestSync()),
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.$store.getters.isSponsor) {
|
||||
this.$store.dispatch('modal/open', 'sponsor');
|
||||
|
@ -40,8 +40,7 @@ export default (desc) => {
|
||||
},
|
||||
},
|
||||
};
|
||||
Object.keys(desc.computedLocalSettings || {}).forEach((key) => {
|
||||
const id = desc.computedLocalSettings[key];
|
||||
Object.entries(desc.computedLocalSettings || {}).forEach(([key, id]) => {
|
||||
component.computed[key] = {
|
||||
get() {
|
||||
return store.getters['data/localSettings'][id];
|
||||
@ -56,10 +55,10 @@ export default (desc) => {
|
||||
component.computed.allTemplates = () => {
|
||||
const allTemplates = store.getters['data/allTemplates'];
|
||||
const sortedTemplates = {};
|
||||
Object.keys(allTemplates)
|
||||
.sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name))
|
||||
.forEach((templateId) => {
|
||||
sortedTemplates[templateId] = allTemplates[templateId];
|
||||
Object.entries(allTemplates)
|
||||
.sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name))
|
||||
.forEach(([templateId, template]) => {
|
||||
sortedTemplates[templateId] = template;
|
||||
});
|
||||
return sortedTemplates;
|
||||
};
|
||||
|
@ -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>
|
13
src/data/defaultLayoutSettings.js
Normal file
13
src/data/defaultLayoutSettings.js
Normal 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',
|
||||
});
|
@ -1,16 +1,5 @@
|
||||
export default () => ({
|
||||
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',
|
||||
pdfExportTemplate: 'styledHtml',
|
||||
pandocExportFormat: 'pdf',
|
||||
|
5
src/data/defaultWorkspaces.js
Normal file
5
src/data/defaultWorkspaces.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default () => ({
|
||||
main: {
|
||||
name: 'Main workspace',
|
||||
},
|
||||
});
|
5
src/icons/Database.vue
Normal file
5
src/icons/Database.vue
Normal 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>
|
@ -1,11 +1,30 @@
|
||||
<template>
|
||||
<div class="icon-provider" :class="['icon-provider--' + providerId]">
|
||||
<div class="icon-provider" :class="'icon-provider--' + classState">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
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>
|
||||
|
||||
@ -22,21 +41,19 @@ export default {
|
||||
background-image: url(../assets/iconStackedit.svg);
|
||||
}
|
||||
|
||||
.icon-provider--googleDrive {
|
||||
.icon-provider--google-drive {
|
||||
background-image: url(../assets/iconGoogleDrive.svg);
|
||||
}
|
||||
|
||||
.icon-provider--googlePhotos {
|
||||
.icon-provider--google-photos {
|
||||
background-image: url(../assets/iconGooglePhotos.svg);
|
||||
}
|
||||
|
||||
.icon-provider--github,
|
||||
.icon-provider--gist {
|
||||
.icon-provider--github {
|
||||
background-image: url(../assets/iconGithub.svg);
|
||||
}
|
||||
|
||||
.icon-provider--dropbox,
|
||||
.icon-provider--dropboxRestricted {
|
||||
.icon-provider--dropbox {
|
||||
background-image: url(../assets/iconDropbox.svg);
|
||||
}
|
||||
|
||||
@ -44,8 +61,7 @@ export default {
|
||||
background-image: url(../assets/iconWordpress.svg);
|
||||
}
|
||||
|
||||
.icon-provider--blogger,
|
||||
.icon-provider--bloggerPage {
|
||||
.icon-provider--blogger {
|
||||
background-image: url(../assets/iconBlogger.svg);
|
||||
}
|
||||
|
||||
|
@ -48,6 +48,7 @@ import Redo from './Redo';
|
||||
import ContentSave from './ContentSave';
|
||||
import Message from './Message';
|
||||
import History from './History';
|
||||
import Database from './Database';
|
||||
|
||||
Vue.component('iconProvider', Provider);
|
||||
Vue.component('iconFormatBold', FormatBold);
|
||||
@ -98,3 +99,4 @@ Vue.component('iconRedo', Redo);
|
||||
Vue.component('iconContentSave', ContentSave);
|
||||
Vue.component('iconMessage', Message);
|
||||
Vue.component('iconHistory', History);
|
||||
Vue.component('iconDatabase', Database);
|
||||
|
@ -15,8 +15,7 @@ export default {
|
||||
|
||||
// Parse JSON value
|
||||
const parsedValue = JSON.parse(jsonValue);
|
||||
Object.keys(parsedValue).forEach((id) => {
|
||||
const value = parsedValue[id];
|
||||
Object.entries(parsedValue).forEach(([id, value]) => {
|
||||
if (value) {
|
||||
const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/);
|
||||
if (v4Match) {
|
||||
@ -56,8 +55,8 @@ export default {
|
||||
});
|
||||
|
||||
// Go through the maps
|
||||
Object.keys(nameMap).forEach(externalId => store.dispatch('createFile', {
|
||||
name: nameMap[externalId],
|
||||
Object.entries(nameMap).forEach(([externalId, name]) => store.dispatch('createFile', {
|
||||
name,
|
||||
parentId: folderIdMap[parentIdMap[externalId]],
|
||||
text: textMap[externalId],
|
||||
properties: propertiesMap[externalId],
|
||||
|
@ -32,6 +32,16 @@ const diffMatchPatch = new DiffMatchPatch();
|
||||
let instantPreview = true;
|
||||
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
|
||||
const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, {
|
||||
// Elements
|
||||
@ -88,7 +98,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
||||
return this.parsingCtx.sections;
|
||||
},
|
||||
getCursorFocusRatio: () => {
|
||||
if (store.getters['data/localSettings'].focusMode) {
|
||||
if (store.getters['data/layoutSettings'].focusMode) {
|
||||
return 1;
|
||||
}
|
||||
return 0.15;
|
||||
@ -128,12 +138,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
||||
sectionDescIdx += 1;
|
||||
if (sectionDesc.editorElt !== section.elt) {
|
||||
// Force textToPreviewDiffs computation
|
||||
sectionDesc = {
|
||||
...sectionDesc,
|
||||
section,
|
||||
editorElt: section.elt,
|
||||
textToPreviewDiffs: null,
|
||||
};
|
||||
sectionDesc = new SectionDesc(
|
||||
section, sectionDesc.previewElt, sectionDesc.tocElt, sectionDesc.html);
|
||||
}
|
||||
newSectionDescList.push(sectionDesc);
|
||||
previewHtml += sectionDesc.html;
|
||||
@ -183,16 +189,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
||||
}
|
||||
|
||||
previewHtml += html;
|
||||
newSectionDescList.push({
|
||||
section,
|
||||
editorElt: section.elt,
|
||||
previewElt: sectionPreviewElt,
|
||||
tocElt: sectionTocElt,
|
||||
html,
|
||||
});
|
||||
newSectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.sectionDescList = newSectionDescList;
|
||||
this.previewHtml = previewHtml.replace(/^\s+|\s+$/g, '');
|
||||
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(() => {
|
||||
const scrollPosition = editorSvc.getScrollPosition() ||
|
||||
@ -342,12 +343,12 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
||||
this.tocElt = tocElt;
|
||||
|
||||
this.createClEditor(editorElt);
|
||||
|
||||
this.clEditor.on('contentChanged', (content, diffs, sectionList) => {
|
||||
const parsingCtx = {
|
||||
this.parsingCtx = {
|
||||
...this.parsingCtx,
|
||||
sectionList,
|
||||
};
|
||||
this.parsingCtx = parsingCtx;
|
||||
});
|
||||
this.clEditor.undoMgr.on('undoStateChange', () => {
|
||||
const canUndo = this.clEditor.undoMgr.canUndo();
|
||||
@ -447,11 +448,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils,
|
||||
};
|
||||
|
||||
const triggerImgCacheGc = debounce(() => {
|
||||
Object.keys(imgCache).forEach((src) => {
|
||||
const entries = imgCache[src]
|
||||
.filter(imgElt => this.editorElt.contains(imgElt));
|
||||
if (entries.length) {
|
||||
imgCache[src] = entries;
|
||||
Object.entries(imgCache).forEach(([src, entries]) => {
|
||||
// Filter entries that are not attached to the DOM
|
||||
const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt));
|
||||
if (filteredEntries.length) {
|
||||
imgCache[src] = filteredEntries;
|
||||
} else {
|
||||
delete imgCache[src];
|
||||
}
|
||||
|
@ -45,8 +45,7 @@ function syncDiscussionMarkers(content, writeOffsets) {
|
||||
...newDiscussion,
|
||||
};
|
||||
}
|
||||
Object.keys(discussionMarkers).forEach((markerKey) => {
|
||||
const marker = discussionMarkers[markerKey];
|
||||
Object.entries(discussionMarkers).forEach(([markerKey, marker]) => {
|
||||
// Remove marker if discussion was removed
|
||||
const discussion = discussions[marker.discussionId];
|
||||
if (!discussion) {
|
||||
@ -55,8 +54,7 @@ function syncDiscussionMarkers(content, writeOffsets) {
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(discussions).forEach((discussionId) => {
|
||||
const discussion = discussions[discussionId];
|
||||
Object.entries(discussions).forEach(([discussionId, discussion]) => {
|
||||
getDiscussionMarkers(discussion, discussionId, writeOffsets
|
||||
? (marker) => {
|
||||
discussion[marker.offsetName] = marker.offset;
|
||||
@ -73,8 +71,8 @@ function syncDiscussionMarkers(content, writeOffsets) {
|
||||
}
|
||||
|
||||
function removeDiscussionMarkers() {
|
||||
Object.keys(discussionMarkers).forEach((markerKey) => {
|
||||
clEditor.removeMarker(discussionMarkers[markerKey]);
|
||||
Object.entries(discussionMarkers).forEach(([, marker]) => {
|
||||
clEditor.removeMarker(marker);
|
||||
});
|
||||
discussionMarkers = {};
|
||||
markerKeys = [];
|
||||
@ -138,25 +136,6 @@ export default {
|
||||
isChangePatch = 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) {
|
||||
const content = store.getters['content/current'];
|
||||
@ -241,9 +220,10 @@ export default {
|
||||
classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId });
|
||||
editorClassAppliers[discussionId] = classApplier;
|
||||
});
|
||||
Object.keys(oldEditorClassAppliers).forEach((discussionId) => {
|
||||
// Clean unused class appliers
|
||||
Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => {
|
||||
if (!editorClassAppliers[discussionId]) {
|
||||
oldEditorClassAppliers[discussionId].stop();
|
||||
classApplier.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@ -255,9 +235,10 @@ export default {
|
||||
classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId });
|
||||
previewClassAppliers[discussionId] = classApplier;
|
||||
});
|
||||
Object.keys(oldPreviewClassAppliers).forEach((discussionId) => {
|
||||
// Clean unused class appliers
|
||||
Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => {
|
||||
if (!previewClassAppliers[discussionId]) {
|
||||
oldPreviewClassAppliers[discussionId].stop();
|
||||
classApplier.stop();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
@ -6,36 +6,25 @@ import store from '../store';
|
||||
const diffMatchPatch = new DiffMatchPatch();
|
||||
|
||||
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.
|
||||
*/
|
||||
getScrollPosition() {
|
||||
const objToScroll = this.getObjectToScroll();
|
||||
const scrollTop = objToScroll.elt.scrollTop;
|
||||
getScrollPosition(elt = store.getters['layout/styles'].showEditor
|
||||
? this.editorElt
|
||||
: this.previewElt,
|
||||
) {
|
||||
const dimensionKey = elt === this.editorElt
|
||||
? 'editorDimension'
|
||||
: 'previewDimension';
|
||||
const scrollTop = elt.parentNode.scrollTop;
|
||||
let result;
|
||||
if (this.sectionDescMeasuredList) {
|
||||
this.sectionDescMeasuredList.some((sectionDesc, sectionIdx) => {
|
||||
if (scrollTop >= sectionDesc[objToScroll.dimensionKey].endOffset) {
|
||||
if (scrollTop >= sectionDesc[dimensionKey].endOffset) {
|
||||
return false;
|
||||
}
|
||||
const posInSection = (scrollTop - sectionDesc[objToScroll.dimensionKey].startOffset) /
|
||||
(sectionDesc[objToScroll.dimensionKey].height || 1);
|
||||
const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) /
|
||||
(sectionDesc[dimensionKey].height || 1);
|
||||
result = {
|
||||
sectionIdx,
|
||||
posInSection,
|
||||
|
@ -15,11 +15,11 @@ const deleteMarkerMaxAge = 1000;
|
||||
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
|
||||
|
||||
class Connection {
|
||||
constructor() {
|
||||
constructor(dbName) {
|
||||
this.getTxCbs = [];
|
||||
|
||||
// Init connexion
|
||||
const request = indexedDB.open('stackedit-db', dbVersion);
|
||||
// Init connection
|
||||
const request = indexedDB.open(dbName, dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
throw new Error("Can't connect to IndexedDB.");
|
||||
@ -39,7 +39,7 @@ class Connection {
|
||||
const oldVersion = event.oldVersion || 0;
|
||||
|
||||
// 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 */
|
||||
switch (oldVersion) {
|
||||
case 0:
|
||||
@ -80,21 +80,174 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
const hashMap = {};
|
||||
utils.types.forEach((type) => {
|
||||
hashMap[type] = Object.create(null);
|
||||
});
|
||||
|
||||
const contentTypes = {
|
||||
content: true,
|
||||
contentState: true,
|
||||
syncedContent: true,
|
||||
};
|
||||
|
||||
const hashMap = {};
|
||||
utils.types.forEach((type) => {
|
||||
hashMap[type] = Object.create(null);
|
||||
});
|
||||
const lsHashMap = Object.create(null);
|
||||
|
||||
const localDbSvc = {
|
||||
lastTx: 0,
|
||||
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
|
||||
@ -103,9 +256,15 @@ const localDbSvc = {
|
||||
*/
|
||||
sync() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create the DB transaction
|
||||
this.connection.createTx((tx) => {
|
||||
// Look for DB changes and apply them to the store
|
||||
this.readAll(tx, (storeItemMap) => {
|
||||
// Persist all the store changes into the DB
|
||||
this.writeAll(storeItemMap, tx);
|
||||
// Sync localStorage
|
||||
this.syncLocalStorage();
|
||||
// Done
|
||||
resolve();
|
||||
});
|
||||
}, () => reject(new Error('Local DB access error.')));
|
||||
@ -186,8 +345,7 @@ const localDbSvc = {
|
||||
});
|
||||
|
||||
// Put changes
|
||||
Object.keys(storeItemMap).forEach((id) => {
|
||||
const storeItem = storeItemMap[id];
|
||||
Object.entries(storeItemMap).forEach(([, storeItem]) => {
|
||||
// Store object has changed
|
||||
if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) {
|
||||
const item = {
|
||||
@ -266,11 +424,11 @@ const localDbSvc = {
|
||||
return this.sync()
|
||||
.then(() => {
|
||||
// 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) => {
|
||||
store.getters[`${type}/items`].forEach((item) => {
|
||||
const [fileId] = item.id.split('/');
|
||||
if (!lastOpenedFileIds.has(fileId)) {
|
||||
if (!lastOpenedFileIdSet.has(fileId)) {
|
||||
// Remove item from the store
|
||||
store.commit(`${type}/deleteItem`, item.id);
|
||||
}
|
||||
@ -303,119 +461,4 @@ const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
|
||||
localDbSvc.loadSyncedContent = loader('syncedContent');
|
||||
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;
|
||||
|
@ -20,15 +20,13 @@ const languageAliases = ({
|
||||
ps1: 'powershell',
|
||||
psm1: 'powershell',
|
||||
});
|
||||
Object.keys(languageAliases).forEach((alias) => {
|
||||
const language = languageAliases[alias];
|
||||
Object.entries(languageAliases).forEach(([alias, language]) => {
|
||||
Prism.languages[alias] = Prism.languages[language];
|
||||
});
|
||||
|
||||
// Add programming language parsing capability to markdown fences
|
||||
const insideFences = {};
|
||||
Object.keys(Prism.languages).forEach((name) => {
|
||||
const language = Prism.languages[name];
|
||||
Object.entries(Prism.languages).forEach(([name, language]) => {
|
||||
if (Prism.util.type(language) === 'Object') {
|
||||
insideFences[`language-${name}`] = {
|
||||
pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`),
|
||||
|
@ -190,8 +190,7 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
Object.keys(defs).forEach((name) => {
|
||||
const def = defs[name];
|
||||
Object.entries(defs).forEach(([name, def]) => {
|
||||
grammars.main[name] = def;
|
||||
grammars.list[name] = def;
|
||||
grammars.blockquote[name] = def;
|
||||
@ -396,8 +395,7 @@ export default {
|
||||
rest.linkref.inside['cl cl-underlined-text'].inside = inside;
|
||||
|
||||
// Wrap any other characters to allow paragraph folding
|
||||
Object.keys(grammars).forEach((key) => {
|
||||
const grammar = grammars[key];
|
||||
Object.entries(grammars).forEach(([, grammar]) => {
|
||||
grammar.rest = grammar.rest || {};
|
||||
grammar.rest.p = /.+/;
|
||||
});
|
||||
|
@ -193,12 +193,8 @@ export default {
|
||||
|
||||
const url = utils.addQueryParams(config.url, config.params);
|
||||
xhr.open(config.method || 'GET', url);
|
||||
Object.keys(config.headers).forEach((key) => {
|
||||
const value = config.headers[key];
|
||||
if (value) {
|
||||
xhr.setRequestHeader(key, `${value}`);
|
||||
}
|
||||
});
|
||||
Object.entries(config.headers).forEach(([key, value]) =>
|
||||
value && xhr.setRequestHeader(key, `${value}`));
|
||||
if (config.blob) {
|
||||
xhr.responseType = 'blob';
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ function throttle(func, wait) {
|
||||
const doScrollSync = () => {
|
||||
const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview;
|
||||
skipAnimation = false;
|
||||
if (!store.getters['data/localSettings'].scrollSync || !sectionDescList || sectionDescList.length === 0) {
|
||||
if (!store.getters['data/layoutSettings'].scrollSync || !sectionDescList || sectionDescList.length === 0) {
|
||||
return;
|
||||
}
|
||||
let editorScrollTop = editorScrollerElt.scrollTop;
|
||||
@ -116,7 +116,7 @@ const forceScrollSync = () => {
|
||||
doScrollSync();
|
||||
}
|
||||
};
|
||||
store.watch(() => store.getters['data/localSettings'].scrollSync, forceScrollSync);
|
||||
store.watch(() => store.getters['data/layoutSettings'].scrollSync, forceScrollSync);
|
||||
|
||||
editorSvc.$on('inited', () => {
|
||||
editorScrollerElt = editorSvc.editorElt.parentNode;
|
||||
|
@ -67,8 +67,7 @@ store.watch(
|
||||
Mousetrap.reset();
|
||||
|
||||
const shortcuts = computedSettings.shortcuts;
|
||||
Object.keys(shortcuts).forEach((key) => {
|
||||
const shortcut = shortcuts[key];
|
||||
Object.entries(shortcuts).forEach(([key, shortcut]) => {
|
||||
if (shortcut) {
|
||||
const method = `${shortcut.method || shortcut}`;
|
||||
let params = shortcut.params || [];
|
||||
|
@ -7,6 +7,10 @@ export default providerRegistry.register({
|
||||
getToken() {
|
||||
return store.getters['data/loginToken'];
|
||||
},
|
||||
initWorkspace() {
|
||||
// Nothing to do since the main workspace isn't necessarily synchronized
|
||||
return Promise.resolve();
|
||||
},
|
||||
getChanges(token) {
|
||||
return googleHelper.getChanges(token)
|
||||
.then((result) => {
|
||||
@ -44,8 +48,10 @@ export default providerRegistry.register({
|
||||
saveItem(token, item, syncData, ifNotTooLate) {
|
||||
return googleHelper.uploadAppDataFile(
|
||||
token,
|
||||
JSON.stringify(item), ['appDataFolder'],
|
||||
null,
|
||||
JSON.stringify(item),
|
||||
['appDataFolder'],
|
||||
undefined,
|
||||
undefined,
|
||||
syncData && syncData.id,
|
||||
ifNotTooLate,
|
||||
)
|
||||
@ -100,6 +106,7 @@ export default providerRegistry.register({
|
||||
hash: item.hash,
|
||||
}),
|
||||
['appDataFolder'],
|
||||
undefined,
|
||||
JSON.stringify(item),
|
||||
syncData && syncData.id,
|
||||
ifNotTooLate,
|
||||
@ -116,6 +123,9 @@ export default providerRegistry.register({
|
||||
},
|
||||
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,
|
||||
@ -125,6 +135,9 @@ export default providerRegistry.register({
|
||||
},
|
||||
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));
|
||||
},
|
||||
|
@ -32,6 +32,7 @@ export default providerRegistry.register({
|
||||
token,
|
||||
name,
|
||||
parents,
|
||||
undefined,
|
||||
providerUtils.serializeContent(content),
|
||||
undefined,
|
||||
syncLocation.driveFileId,
|
||||
@ -47,6 +48,7 @@ export default providerRegistry.register({
|
||||
token,
|
||||
metadata.title,
|
||||
[],
|
||||
undefined,
|
||||
html,
|
||||
publishLocation.templateId ? 'text/html' : undefined,
|
||||
publishLocation.driveFileId,
|
||||
|
256
src/services/providers/googleDriveWorkspaceProvider.js
Normal file
256
src/services/providers/googleDriveWorkspaceProvider.js
Normal 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);
|
||||
},
|
||||
});
|
@ -53,7 +53,16 @@ export default {
|
||||
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()
|
||||
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
|
||||
.then(ifNotTooLate(() => {
|
||||
@ -61,7 +70,7 @@ export default {
|
||||
method: 'POST',
|
||||
url: 'https://www.googleapis.com/drive/v3/files',
|
||||
};
|
||||
const metadata = { name };
|
||||
const metadata = { name, appProperties };
|
||||
if (fileId) {
|
||||
options.method = 'PATCH';
|
||||
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 += JSON.stringify(metadata);
|
||||
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 += closeDelimiter;
|
||||
options.url = options.url.replace(
|
||||
@ -95,6 +104,9 @@ export default {
|
||||
body: multipartRequestBody,
|
||||
}).then(res => res.body);
|
||||
}
|
||||
if (mediaType) {
|
||||
metadata.mimeType = mediaType;
|
||||
}
|
||||
return this.request(refreshedToken, {
|
||||
...options,
|
||||
body: metadata,
|
||||
@ -170,7 +182,7 @@ export default {
|
||||
.then(token => this.getUser(token.sub)
|
||||
.catch((err) => {
|
||||
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 {
|
||||
throw err;
|
||||
}
|
||||
@ -311,15 +323,23 @@ export default {
|
||||
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))
|
||||
.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)
|
||||
.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) {
|
||||
return this.refreshToken(token, getDriveScopes(token))
|
||||
|
@ -62,8 +62,8 @@ export default {
|
||||
*/
|
||||
openFileWithLocation(allLocations, criteria) {
|
||||
return allLocations.some((location) => {
|
||||
// If every field fits the criteria
|
||||
if (Object.keys(criteria).every(key => criteria[key] === location[key])) {
|
||||
// If every field fits the criteria
|
||||
if (Object.entries(criteria).every(([key, value]) => value === location[key])) {
|
||||
// Found one location that fits, open it if it exists
|
||||
const file = store.state.file.itemMap[location.fileId];
|
||||
if (file) {
|
||||
|
@ -3,38 +3,73 @@ import store from '../store';
|
||||
import utils from './utils';
|
||||
import diffUtils from './diffUtils';
|
||||
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 restartSyncAfter = 30 * 1000; // 30 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;
|
||||
|
||||
/**
|
||||
* Return true if we are online and we have something to sync.
|
||||
*/
|
||||
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() {
|
||||
const storedLastSyncActivity = getLastStoredSyncActivity();
|
||||
return lastSyncActivity === storedLastSyncActivity ||
|
||||
Date.now() > inactivityThreshold + storedLastSyncActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if auto sync can start, ie that lastSyncActivity is old enough.
|
||||
*/
|
||||
function isAutoSyncReady() {
|
||||
const storedLastSyncActivity = getLastStoredSyncActivity();
|
||||
return Date.now() > autoSyncAfter + storedLastSyncActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastSyncActivity, assuming we have the lock.
|
||||
*/
|
||||
function setLastSyncActivity() {
|
||||
const currentDate = Date.now();
|
||||
lastSyncActivity = currentDate;
|
||||
localStorage[lastSyncActivityKey] = currentDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a syncedContent.
|
||||
*/
|
||||
function cleanSyncedContent(syncedContent) {
|
||||
// Clean syncHistory from removed syncLocations
|
||||
Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {
|
||||
@ -42,17 +77,21 @@ function cleanSyncedContent(syncedContent) {
|
||||
delete syncedContent.syncHistory[syncLocationId];
|
||||
}
|
||||
});
|
||||
const allSyncLocationHashes = new Set([].concat(
|
||||
const allSyncLocationHashSet = new Set([].concat(
|
||||
...Object.keys(syncedContent.syncHistory).map(
|
||||
id => syncedContent.syncHistory[id])));
|
||||
// Clean historyData from unused contents
|
||||
Object.keys(syncedContent.historyData).map(hash => parseInt(hash, 10)).forEach((hash) => {
|
||||
if (!allSyncLocationHashes.has(hash)) {
|
||||
if (!allSyncLocationHashSet.has(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) {
|
||||
const storeItemMap = { ...store.getters.allItemMap };
|
||||
const syncData = { ...store.getters['data/syncData'] };
|
||||
@ -92,6 +131,9 @@ function applyChanges(changes) {
|
||||
const LAST_SENT = 0;
|
||||
const LAST_MERGED = 1;
|
||||
|
||||
/**
|
||||
* Create a sync location by uploading the current file content.
|
||||
*/
|
||||
function createSyncLocation(syncLocation) {
|
||||
syncLocation.id = utils.uid();
|
||||
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()) {
|
||||
const fileSyncContext = new FileSyncContext();
|
||||
syncContext.synced[`${fileId}/content`] = true;
|
||||
@ -174,7 +219,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
|
||||
const syncLocations = [
|
||||
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
|
||||
];
|
||||
if (isDataSyncPossible()) {
|
||||
if (isWorkspaceSyncPossible()) {
|
||||
syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId });
|
||||
}
|
||||
let result;
|
||||
@ -349,7 +394,9 @@ function syncFile(fileId, syncContext = new SyncContext()) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sync a data item, typically settings and templates.
|
||||
*/
|
||||
function syncDataItem(dataId) {
|
||||
const item = store.state.data.itemMap[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 mainToken = store.getters['data/loginToken'];
|
||||
return mainProvider.getChanges(mainToken)
|
||||
@ -447,8 +497,7 @@ function sync() {
|
||||
};
|
||||
const syncDataByItemId = store.getters['data/syncDataByItemId'];
|
||||
let result;
|
||||
Object.keys(storeItemMap).some((id) => {
|
||||
const item = storeItemMap[id];
|
||||
Object.entries(storeItemMap).some(([id, item]) => {
|
||||
const existingSyncData = syncDataByItemId[id];
|
||||
if ((!existingSyncData || existingSyncData.hash !== item.hash) &&
|
||||
// Add file if content has been uploaded
|
||||
@ -483,8 +532,7 @@ function sync() {
|
||||
};
|
||||
const syncData = store.getters['data/syncData'];
|
||||
let result;
|
||||
Object.keys(syncData).some((id) => {
|
||||
const existingSyncData = syncData[id];
|
||||
Object.entries(syncData).some(([, existingSyncData]) => {
|
||||
if (!storeItemMap[existingSyncData.itemId] &&
|
||||
// Remove content only if file has been removed
|
||||
(existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]])
|
||||
@ -556,20 +604,23 @@ function sync() {
|
||||
() => {
|
||||
if (syncContext.restart) {
|
||||
// Restart sync
|
||||
return sync();
|
||||
return syncWorkspace();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(err) => {
|
||||
if (err && err.message === 'TOO_LATE') {
|
||||
// Restart sync
|
||||
return sync();
|
||||
return syncWorkspace();
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a sync task, if possible.
|
||||
*/
|
||||
function requestSync() {
|
||||
store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => {
|
||||
let intervalId;
|
||||
@ -607,8 +658,8 @@ function requestSync() {
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (isDataSyncPossible()) {
|
||||
return sync();
|
||||
if (isWorkspaceSyncPossible()) {
|
||||
return syncWorkspace();
|
||||
}
|
||||
if (hasCurrentFileSyncLocations()) {
|
||||
// Only sync current file if data sync is unavailable.
|
||||
@ -620,9 +671,9 @@ function requestSync() {
|
||||
})
|
||||
.then(() => {
|
||||
// Clean files
|
||||
Object.keys(fileHashesToClean).forEach((fileId) => {
|
||||
Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => {
|
||||
const file = store.state.file.itemMap[fileId];
|
||||
if (file && file.hash === fileHashesToClean[fileId]) {
|
||||
if (file && file.hash === fileHash) {
|
||||
store.dispatch('deleteFile', fileId);
|
||||
}
|
||||
});
|
||||
@ -635,26 +686,41 @@ function requestSync() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Sync periodically
|
||||
utils.setInterval(() => {
|
||||
if (isSyncPossible() &&
|
||||
utils.isUserActive() &&
|
||||
isSyncWindow() &&
|
||||
isAutoSyncReady()
|
||||
) {
|
||||
requestSync();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Unload contents from memory periodically
|
||||
utils.setInterval(() => {
|
||||
// Wait for sync and publish to finish
|
||||
if (store.state.queue.isEmpty) {
|
||||
localDbSvc.unloadContents();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
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
|
||||
utils.setInterval(() => {
|
||||
if (isSyncPossible() &&
|
||||
utils.isUserActive() &&
|
||||
isSyncWindow() &&
|
||||
isAutoSyncReady()
|
||||
) {
|
||||
requestSync();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Unload contents from memory periodically
|
||||
utils.setInterval(() => {
|
||||
// Wait for sync and publish to finish
|
||||
if (store.state.queue.isEmpty) {
|
||||
localDbSvc.unloadContents();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
},
|
||||
isSyncPossible,
|
||||
requestSync,
|
||||
createSyncLocation,
|
||||
|
@ -31,7 +31,9 @@ const setLastFocus = () => {
|
||||
localStorage[lastFocusKey] = lastFocus;
|
||||
setLastActivity();
|
||||
};
|
||||
setLastFocus();
|
||||
if (document.hasFocus()) {
|
||||
setLastFocus();
|
||||
}
|
||||
window.addEventListener('focus', setLastFocus);
|
||||
|
||||
// For parseQueryParams()
|
||||
@ -64,6 +66,12 @@ export default {
|
||||
'publishLocation',
|
||||
'data',
|
||||
],
|
||||
localStorageDataIds: [
|
||||
'workspaces',
|
||||
'settings',
|
||||
'layoutSettings',
|
||||
'tokens',
|
||||
],
|
||||
textMaxLength: 150000,
|
||||
sanitizeText(text) {
|
||||
const result = `${text || ''}`.slice(0, this.textMaxLength);
|
||||
@ -148,10 +156,10 @@ export default {
|
||||
parseQueryParams,
|
||||
addQueryParams(url = '', params = {}) {
|
||||
const keys = Object.keys(params).filter(key => params[key] != null);
|
||||
if (!keys.length) {
|
||||
return url;
|
||||
}
|
||||
urlParser.href = url;
|
||||
if (!keys.length) {
|
||||
return urlParser.href;
|
||||
}
|
||||
if (urlParser.search) {
|
||||
urlParser.search += '&';
|
||||
} else {
|
||||
@ -180,8 +188,8 @@ export default {
|
||||
startOffset = 0;
|
||||
}
|
||||
const elt = document.createElement('span');
|
||||
Object.keys(eltProperties).forEach((key) => {
|
||||
elt[key] = eltProperties[key];
|
||||
Object.entries(eltProperties).forEach(([key, value]) => {
|
||||
elt[key] = value;
|
||||
});
|
||||
treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode);
|
||||
elt.appendChild(treeWalker.currentNode);
|
||||
|
@ -81,8 +81,7 @@ module.actions = {
|
||||
const diffs = diffMatchPatch.diff_main(
|
||||
currentContent.text, revisionContent.originalText);
|
||||
diffMatchPatch.diff_cleanupSemantic(diffs);
|
||||
Object.keys(currentContent.discussions).forEach((discussionId) => {
|
||||
const discussion = currentContent.discussions[discussionId];
|
||||
Object.entries(currentContent.discussions).forEach(([, discussion]) => {
|
||||
const adjustOffset = (offsetName) => {
|
||||
const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end');
|
||||
marker.adjustOffset(diffs);
|
||||
|
@ -1,9 +1,10 @@
|
||||
import Vue from 'vue';
|
||||
import yaml from 'js-yaml';
|
||||
import moduleTemplate from './moduleTemplate';
|
||||
import utils from '../services/utils';
|
||||
import defaultWorkspaces from '../data/defaultWorkspaces';
|
||||
import defaultSettings from '../data/defaultSettings.yml';
|
||||
import defaultLocalSettings from '../data/defaultLocalSettings';
|
||||
import defaultLayoutSettings from '../data/defaultLayoutSettings';
|
||||
import plainHtmlTemplate from '../data/plainHtmlTemplate.html';
|
||||
import styledHtmlTemplate from '../data/styledHtmlTemplate.html';
|
||||
import styledHtmlWithTocTemplate from '../data/styledHtmlWithTocTemplate.html';
|
||||
@ -13,36 +14,31 @@ const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
|
||||
|
||||
const empty = (id) => {
|
||||
switch (id) {
|
||||
case 'workspaces':
|
||||
return itemTemplate(id, defaultWorkspaces());
|
||||
case 'settings':
|
||||
return itemTemplate(id, '\n');
|
||||
case 'localSettings':
|
||||
return itemTemplate(id, defaultLocalSettings());
|
||||
case 'layoutSettings':
|
||||
return itemTemplate(id, defaultLayoutSettings());
|
||||
default:
|
||||
return itemTemplate(id);
|
||||
}
|
||||
};
|
||||
const module = moduleTemplate(empty, true);
|
||||
|
||||
module.mutations.setItem = (state, value) => {
|
||||
const emptyItem = empty(value.id);
|
||||
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);
|
||||
};
|
||||
// Item IDs that will be stored in the localStorage
|
||||
const lsItemIdSet = new Set(utils.localStorageDataIds);
|
||||
|
||||
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 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', {
|
||||
...empty(id),
|
||||
data: typeof data === 'object' ? {
|
||||
@ -52,18 +48,10 @@ const patcher = id => ({ state, commit }, data) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Local settings
|
||||
module.getters.localSettings = getter('localSettings');
|
||||
module.actions.patchLocalSettings = patcher('localSettings');
|
||||
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
|
||||
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
|
||||
// For layoutSettings
|
||||
const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLayoutSettings', {
|
||||
[propertyName]: value === undefined ? !getters.layoutSettings[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 constants = getters['layout/constants'];
|
||||
const showGutter = getters['discussion/currentDiscussion'];
|
||||
@ -73,60 +61,8 @@ const notEnoughSpace = (getters) => {
|
||||
constants.buttonBarWidth +
|
||||
(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
|
||||
module.getters.settings = getter('settings');
|
||||
module.getters.computedSettings = (state, getters) => {
|
||||
const customSettings = yaml.safeLoad(getters.settings);
|
||||
const settings = yaml.safeLoad(defaultSettings);
|
||||
const override = (obj, opt) => {
|
||||
const objType = Object.prototype.toString.call(obj);
|
||||
const optType = Object.prototype.toString.call(opt);
|
||||
if (objType !== optType) {
|
||||
return obj;
|
||||
} else if (objType !== '[object Object]') {
|
||||
return opt;
|
||||
}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (key === 'shortcuts') {
|
||||
obj[key] = Object.assign(obj[key], opt[key]);
|
||||
} else {
|
||||
obj[key] = override(obj[key], opt[key]);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
return override(settings, customSettings);
|
||||
};
|
||||
module.actions.setSettings = setter('settings');
|
||||
|
||||
// Templates
|
||||
module.getters.templates = getter('templates');
|
||||
// For templates
|
||||
const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
|
||||
name,
|
||||
value,
|
||||
@ -140,100 +76,8 @@ const additionalTemplates = {
|
||||
styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate),
|
||||
jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
|
||||
};
|
||||
module.getters.allTemplates = (state, getters) => ({
|
||||
...getters.templates,
|
||||
...additionalTemplates,
|
||||
});
|
||||
module.actions.setTemplates = ({ commit }, data) => {
|
||||
const dataToCommit = {
|
||||
...data,
|
||||
};
|
||||
// We don't store additional templates
|
||||
Object.keys(additionalTemplates).forEach((id) => {
|
||||
delete dataToCommit[id];
|
||||
});
|
||||
commit('setItem', itemTemplate('templates', dataToCommit));
|
||||
};
|
||||
|
||||
// Last opened
|
||||
module.getters.lastOpened = getter('lastOpened');
|
||||
module.getters.lastOpenedIds = (state, getters, rootState) => {
|
||||
const lastOpened = {
|
||||
...getters.lastOpened,
|
||||
};
|
||||
const currentFileId = rootState.file.currentId;
|
||||
if (currentFileId && !lastOpened[currentFileId]) {
|
||||
lastOpened[currentFileId] = Date.now();
|
||||
}
|
||||
return Object.keys(lastOpened)
|
||||
.filter(id => rootState.file.itemMap[id])
|
||||
.sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
|
||||
.slice(0, 20);
|
||||
};
|
||||
module.actions.setLastOpenedId = ({ getters, commit, dispatch, rootState }, fileId) => {
|
||||
const lastOpened = { ...getters.lastOpened };
|
||||
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 syncData = getters.syncData;
|
||||
Object.keys(syncData).forEach((id) => {
|
||||
const value = syncData[id];
|
||||
result[value.itemId] = value;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
module.getters.syncDataByType = (state, getters) => {
|
||||
const result = {};
|
||||
utils.types.forEach((type) => {
|
||||
result[type] = {};
|
||||
});
|
||||
const syncData = getters.syncData;
|
||||
Object.keys(syncData).forEach((id) => {
|
||||
const item = syncData[id];
|
||||
if (result[item.type]) {
|
||||
result[item.type][item.itemId] = item;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
module.actions.patchSyncData = patcher('syncData');
|
||||
module.actions.setSyncData = setter('syncData');
|
||||
|
||||
// Data sync data (used to sync settings and settings)
|
||||
module.getters.dataSyncData = getter('dataSyncData');
|
||||
module.actions.patchDataSyncData = patcher('dataSyncData');
|
||||
|
||||
// Tokens
|
||||
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
|
||||
const googleTokens = getters.googleTokens;
|
||||
const loginSubs = Object.keys(googleTokens)
|
||||
.filter(sub => googleTokens[sub].isLogin);
|
||||
return googleTokens[loginSubs[0]];
|
||||
};
|
||||
module.actions.patchTokens = patcher('tokens');
|
||||
// For tokens
|
||||
const tokenSetter = providerId => ({ getters, dispatch }, token) => {
|
||||
dispatch('patchTokens', {
|
||||
[providerId]: {
|
||||
@ -242,10 +86,213 @@ const tokenSetter = providerId => ({ getters, dispatch }, token) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
module.actions.setGoogleToken = tokenSetter('google');
|
||||
module.actions.setDropboxToken = tokenSetter('dropbox');
|
||||
module.actions.setGithubToken = tokenSetter('github');
|
||||
module.actions.setWordpressToken = tokenSetter('wordpress');
|
||||
module.actions.setZendeskToken = tokenSetter('zendesk');
|
||||
|
||||
export default module;
|
||||
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 settings = yaml.safeLoad(defaultSettings);
|
||||
const override = (obj, opt) => {
|
||||
const objType = Object.prototype.toString.call(obj);
|
||||
const optType = Object.prototype.toString.call(opt);
|
||||
if (objType !== optType) {
|
||||
return obj;
|
||||
} else if (objType !== '[object Object]') {
|
||||
return opt;
|
||||
}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (key === 'shortcuts') {
|
||||
obj[key] = Object.assign(obj[key], opt[key]);
|
||||
} else {
|
||||
obj[key] = override(obj[key], opt[key]);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
return override(settings, customSettings);
|
||||
},
|
||||
localSettings: getter('localSettings'),
|
||||
layoutSettings: getter('layoutSettings'),
|
||||
templates: getter('templates'),
|
||||
allTemplates: (state, getters) => ({
|
||||
...getters.templates,
|
||||
...additionalTemplates,
|
||||
}),
|
||||
lastOpened: getter('lastOpened'),
|
||||
lastOpenedIds: (state, getters, rootState) => {
|
||||
const lastOpened = {
|
||||
...getters.lastOpened,
|
||||
};
|
||||
const currentFileId = rootState.file.currentId;
|
||||
if (currentFileId && !lastOpened[currentFileId]) {
|
||||
lastOpened[currentFileId] = Date.now();
|
||||
}
|
||||
return Object.keys(lastOpened)
|
||||
.filter(id => rootState.file.itemMap[id])
|
||||
.sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
|
||||
.slice(0, 20);
|
||||
},
|
||||
syncData: getter('syncData'),
|
||||
syncDataByItemId: (state, getters) => {
|
||||
const result = {};
|
||||
Object.entries(getters.syncData).forEach(([, value]) => {
|
||||
result[value.itemId] = value;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
syncDataByType: (state, getters) => {
|
||||
const result = {};
|
||||
utils.types.forEach((type) => {
|
||||
result[type] = {};
|
||||
});
|
||||
Object.entries(getters.syncData).forEach(([, item]) => {
|
||||
if (result[item.type]) {
|
||||
result[item.type][item.itemId] = item;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dataSyncData: getter('dataSyncData'),
|
||||
tokens: getter('tokens'),
|
||||
googleTokens: (state, getters) => getters.tokens.google || {},
|
||||
dropboxTokens: (state, getters) => getters.tokens.dropbox || {},
|
||||
githubTokens: (state, getters) => getters.tokens.github || {},
|
||||
wordpressTokens: (state, getters) => getters.tokens.wordpress || {},
|
||||
zendeskTokens: (state, getters) => getters.tokens.zendesk || {},
|
||||
loginToken: (state, getters) => {
|
||||
// Return the first google token that has the isLogin flag
|
||||
const googleTokens = getters.googleTokens;
|
||||
const loginSubs = Object.keys(googleTokens)
|
||||
.filter(sub => googleTokens[sub].isLogin);
|
||||
return googleTokens[loginSubs[0]];
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setWorkspaces: setter('workspaces'),
|
||||
patchWorkspaces: patcher('workspaces'),
|
||||
setSettings: setter('settings'),
|
||||
patchLocalSettings: patcher('localSettings'),
|
||||
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');
|
||||
|
||||
// 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'),
|
||||
},
|
||||
};
|
||||
|
@ -65,8 +65,7 @@ export default {
|
||||
const discussions = rootGetters['content/current'].discussions;
|
||||
const comments = rootGetters['content/current'].comments;
|
||||
const discussionLastComments = {};
|
||||
Object.keys(comments).forEach((commentId) => {
|
||||
const comment = comments[commentId];
|
||||
Object.entries(comments).forEach(([, comment]) => {
|
||||
if (discussions[comment.discussionId]) {
|
||||
const lastComment = discussionLastComments[comment.discussionId];
|
||||
if (!lastComment || lastComment.created < comment.created) {
|
||||
@ -84,10 +83,10 @@ export default {
|
||||
}
|
||||
const discussions = rootGetters['content/current'].discussions;
|
||||
const discussionLastComments = getters.currentFileDiscussionLastComments;
|
||||
Object.keys(discussionLastComments)
|
||||
.sort((id1, id2) =>
|
||||
discussionLastComments[id2].created - discussionLastComments[id1].created)
|
||||
.forEach((discussionId) => {
|
||||
Object.entries(discussionLastComments)
|
||||
.sort(([, lastComment1], [, lastComment2]) =>
|
||||
lastComment1.created - lastComment2.created)
|
||||
.forEach(([discussionId]) => {
|
||||
currentFileDiscussions[discussionId] = discussions[discussionId];
|
||||
});
|
||||
return currentFileDiscussions;
|
||||
@ -100,13 +99,13 @@ export default {
|
||||
const comments = {};
|
||||
if (getters.currentDiscussion) {
|
||||
const contentComments = rootGetters['content/current'].comments;
|
||||
Object.keys(contentComments)
|
||||
.filter(commentId =>
|
||||
contentComments[commentId].discussionId === state.currentDiscussionId)
|
||||
.sort((id1, id2) =>
|
||||
contentComments[id1].created - contentComments[id2].created)
|
||||
.forEach((commentId) => {
|
||||
comments[commentId] = contentComments[commentId];
|
||||
Object.entries(contentComments)
|
||||
.filter(([, comment]) =>
|
||||
comment.discussionId === state.currentDiscussionId)
|
||||
.sort(([, comment1], [, comment2]) =>
|
||||
comment1.created - comment2.created)
|
||||
.forEach(([commentId, comment]) => {
|
||||
comments[commentId] = comment;
|
||||
});
|
||||
}
|
||||
return comments;
|
||||
@ -126,10 +125,11 @@ export default {
|
||||
createNewDiscussion({ commit, dispatch, rootGetters }, selection) {
|
||||
const loginToken = rootGetters['data/loginToken'];
|
||||
if (!loginToken) {
|
||||
dispatch('modal/signInForComment', null, { root: true })
|
||||
.then(() => googleHelper.signin())
|
||||
.then(() => syncSvc.requestSync())
|
||||
.then(() => dispatch('createNewDiscussion', selection))
|
||||
dispatch('modal/signInForComment', {
|
||||
onResolve: () => googleHelper.signin()
|
||||
.then(() => syncSvc.requestSync())
|
||||
.then(() => dispatch('createNewDiscussion', selection)),
|
||||
}, { root: true })
|
||||
.catch(() => { }); // Cancel
|
||||
} else if (selection) {
|
||||
let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim();
|
||||
@ -150,8 +150,7 @@ export default {
|
||||
discussions: {},
|
||||
comments: {},
|
||||
};
|
||||
Object.keys(comments).forEach((commentId) => {
|
||||
const comment = comments[commentId];
|
||||
Object.entries(comments).forEach(([commentId, comment]) => {
|
||||
const discussion = discussions[comment.discussionId];
|
||||
if (discussion && comment !== filterComment && discussion !== filterDiscussion) {
|
||||
patch.discussions[comment.discussionId] = discussion;
|
||||
|
@ -87,8 +87,7 @@ export default {
|
||||
nodeMap[item.id] = new Node(item, locations);
|
||||
});
|
||||
const rootNode = new Node(emptyFolder(), [], true, true);
|
||||
Object.keys(nodeMap).forEach((id) => {
|
||||
const node = nodeMap[id];
|
||||
Object.entries(nodeMap).forEach(([id, node]) => {
|
||||
let parentNode = nodeMap[node.item.parentId];
|
||||
if (!parentNode || !parentNode.isFolder) {
|
||||
if (id === 'trash') {
|
||||
|
@ -43,7 +43,6 @@ const store = new Vuex.Store({
|
||||
userInfo,
|
||||
},
|
||||
state: {
|
||||
ready: false,
|
||||
offline: false,
|
||||
lastOfflineCheck: 0,
|
||||
minuteCounter: 0,
|
||||
@ -61,9 +60,6 @@ const store = new Vuex.Store({
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setReady: (state) => {
|
||||
state.ready = true;
|
||||
},
|
||||
setOffline: (state, value) => {
|
||||
state.offline = value;
|
||||
},
|
||||
|
@ -20,14 +20,14 @@ const constants = {
|
||||
statusBarHeight: 20,
|
||||
};
|
||||
|
||||
function computeStyles(state, getters, localSettings = getters['data/localSettings'], styles = {
|
||||
showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar,
|
||||
showStatusBar: localSettings.showStatusBar,
|
||||
showEditor: localSettings.showEditor,
|
||||
showSidePreview: localSettings.showSidePreview && localSettings.showEditor,
|
||||
showPreview: localSettings.showSidePreview || !localSettings.showEditor,
|
||||
showSideBar: localSettings.showSideBar,
|
||||
showExplorer: localSettings.showExplorer,
|
||||
function computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = {
|
||||
showNavigationBar: !layoutSettings.showEditor || layoutSettings.showNavigationBar,
|
||||
showStatusBar: layoutSettings.showStatusBar,
|
||||
showEditor: layoutSettings.showEditor,
|
||||
showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor,
|
||||
showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor,
|
||||
showSideBar: layoutSettings.showSideBar,
|
||||
showExplorer: layoutSettings.showExplorer,
|
||||
layoutOverflow: false,
|
||||
}) {
|
||||
styles.innerHeight = state.layout.bodyHeight;
|
||||
@ -64,7 +64,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
|
||||
styles.showSidePreview = false;
|
||||
styles.showPreview = false;
|
||||
styles.layoutOverflow = false;
|
||||
return computeStyles(state, getters, localSettings, styles);
|
||||
return computeStyles(state, getters, layoutSettings, styles);
|
||||
}
|
||||
|
||||
const computedSettings = getters['data/computedSettings'];
|
||||
@ -96,7 +96,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
|
||||
if (!styles.showSidePreview) {
|
||||
styles.previewWidth += constants.buttonBarWidth;
|
||||
}
|
||||
styles.previewGutterWidth = showGutter && !localSettings.showEditor
|
||||
styles.previewGutterWidth = showGutter && !layoutSettings.showEditor
|
||||
? constants.gutterWidth
|
||||
: 0;
|
||||
const previewLeftPadding = previewRightPadding + styles.previewGutterWidth;
|
||||
@ -107,7 +107,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
|
||||
doublePanelWidth;
|
||||
const editorRightPadding = Math.max(
|
||||
Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);
|
||||
styles.editorGutterWidth = showGutter && localSettings.showEditor
|
||||
styles.editorGutterWidth = showGutter && layoutSettings.showEditor
|
||||
? constants.gutterWidth
|
||||
: 0;
|
||||
const editorLeftPadding = editorRightPadding + styles.editorGutterWidth;
|
||||
@ -129,8 +129,8 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin
|
||||
styles.hideLocations = true;
|
||||
}
|
||||
}
|
||||
styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth);
|
||||
styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth);
|
||||
styles.titleMaxWidth = Math.max(minTitleMaxWidth,
|
||||
Math.min(maxTitleMaxWidth, styles.titleMaxWidth));
|
||||
return styles;
|
||||
}
|
||||
|
||||
@ -162,8 +162,8 @@ export default {
|
||||
updateBodySize({ commit, dispatch, rootGetters }) {
|
||||
commit('updateBodySize');
|
||||
// Make sure both explorer and side bar are not open if body width is small
|
||||
const localSettings = rootGetters['data/localSettings'];
|
||||
dispatch('data/toggleExplorer', localSettings.showExplorer, { root: true });
|
||||
const layoutSettings = rootGetters['data/layoutSettings'];
|
||||
dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -86,24 +86,33 @@ export default {
|
||||
rejectText: 'Cancel',
|
||||
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',
|
||||
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>`,
|
||||
resolveText: 'Ok, sign in',
|
||||
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>
|
||||
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
|
||||
resolveText: 'Ok, sign in',
|
||||
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>
|
||||
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
|
||||
resolveText: 'Ok, sign in',
|
||||
rejectText: 'Cancel',
|
||||
onResolve,
|
||||
}),
|
||||
sponsorOnly: ({ dispatch }) => dispatch('open', {
|
||||
content: '<p>This feature is restricted to <b>sponsor users</b> as it relies on server resources.</p>',
|
||||
|
@ -33,7 +33,7 @@ export default (empty, simpleHash = false) => {
|
||||
itemMap: {},
|
||||
},
|
||||
getters: {
|
||||
items: state => Object.keys(state.itemMap).map(key => state.itemMap[key]),
|
||||
items: state => Object.entries(state.itemMap).map(([, item]) => item),
|
||||
},
|
||||
mutations: {
|
||||
setItem,
|
||||
|
29
src/store/workspace.js
Normal file
29
src/store/workspace.js
Normal 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: {
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue
Block a user