Workspaces (part 1)
This commit is contained in:
		
							parent
							
								
									3a08bc617e
								
							
						
					
					
						commit
						9596339684
					
				| @ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <div class="app"> | ||||
|     <splash-screen v-if="!ready"></splash-screen> | ||||
|   <div v-else class="app"> | ||||
|     <layout></layout> | ||||
|     <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 { | ||||
|           // 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,6 +1,8 @@ | ||||
| <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="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> | ||||
| @ -9,8 +11,10 @@ | ||||
|           <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)) | ||||
|  | ||||
| @ -63,7 +63,7 @@ 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 (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,6 +686,21 @@ function requestSync() { | ||||
|   })); | ||||
| } | ||||
| 
 | ||||
| export default { | ||||
|   init() { | ||||
|     // Load workspaces and tokens from localStorage
 | ||||
|     localDbSvc.syncLocalStorage(); | ||||
| 
 | ||||
|     // Try to find a suitable workspace provider
 | ||||
|     workspaceProvider = providerRegistry.providers[utils.queryParams.providerId]; | ||||
|     if (!workspaceProvider || !workspaceProvider.initWorkspace) { | ||||
|       workspaceProvider = googleDriveAppDataProvider; | ||||
|     } | ||||
| 
 | ||||
|     return workspaceProvider.initWorkspace() | ||||
|       .then(workspace => store.commit('workspace/setCurrentWorkspaceId', workspace.id)) | ||||
|       .then(() => localDbSvc.init()) | ||||
|       .then(() => { | ||||
|         // Sync periodically
 | ||||
|         utils.setInterval(() => { | ||||
|           if (isSyncPossible() && | ||||
| @ -653,8 +719,8 @@ utils.setInterval(() => { | ||||
|             localDbSvc.unloadContents(); | ||||
|           } | ||||
|         }, 5000); | ||||
| 
 | ||||
| export default { | ||||
|       }); | ||||
|   }, | ||||
|   isSyncPossible, | ||||
|   requestSync, | ||||
|   createSyncLocation, | ||||
|  | ||||
| @ -31,7 +31,9 @@ const setLastFocus = () => { | ||||
|   localStorage[lastFocusKey] = lastFocus; | ||||
|   setLastActivity(); | ||||
| }; | ||||
| 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,35 +61,89 @@ 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) => { | ||||
| // For templates
 | ||||
| const makeAdditionalTemplate = (name, value, helpers = '\n') => ({ | ||||
|   name, | ||||
|   value, | ||||
|   helpers, | ||||
|   isAdditional: true, | ||||
| }); | ||||
| const additionalTemplates = { | ||||
|   plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'), | ||||
|   plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate), | ||||
|   styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate), | ||||
|   styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate), | ||||
|   jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), | ||||
| }; | ||||
| 
 | ||||
| // For tokens
 | ||||
| const tokenSetter = providerId => ({ getters, dispatch }, token) => { | ||||
|   dispatch('patchTokens', { | ||||
|     [providerId]: { | ||||
|       ...getters[`${providerId}Tokens`], | ||||
|       [token.sub]: token, | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state: { | ||||
|     // Data items stored in the DB
 | ||||
|     itemMap: {}, | ||||
|     // Data items stored in the localStorage
 | ||||
|     lsItemMap: {}, | ||||
|   }, | ||||
|   mutations: { | ||||
|     setItem: (state, value) => { | ||||
|       // Create an empty item and override its data field
 | ||||
|       const emptyItem = empty(value.id); | ||||
|       const data = typeof value.data === 'object' | ||||
|         ? Object.assign(emptyItem.data, value.data) | ||||
|         : value.data; | ||||
|       const item = { | ||||
|         ...emptyItem, | ||||
|         data, | ||||
|       }; | ||||
| 
 | ||||
|       // Calculate item hash
 | ||||
|       item.hash = utils.hash(utils.serializeObject({ | ||||
|         ...item, | ||||
|         hash: undefined, | ||||
|       })); | ||||
| 
 | ||||
|       // Store item in itemMap or lsItemMap if its stored in the localStorage
 | ||||
|       Vue.set(lsItemIdSet.has(item.id) ? state.lsItemMap : state.itemMap, item.id, item); | ||||
|     }, | ||||
|   }, | ||||
|   getters: { | ||||
|     workspaces: (state) => { | ||||
|       const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data; | ||||
|       const result = {}; | ||||
|       Object.entries(workspaces).forEach(([id, workspace]) => { | ||||
|         result[id] = { | ||||
|           ...workspace, | ||||
|           id, | ||||
|           providerId: workspace.providerId || 'googleDriveWorkspace', | ||||
|           url: utils.addQueryParams('app'), | ||||
|         }; | ||||
|       }); | ||||
|       return result; | ||||
|     }, | ||||
|     dbName: (state, getters) => { | ||||
|       let dbName; | ||||
|       Object.keys(getters.workspaces).some((id) => { | ||||
|         dbName = 'stackedit-db'; | ||||
|         if (id !== 'main') { | ||||
|           dbName += `-${id}`; | ||||
|         } | ||||
|         return dbName; | ||||
|       }); | ||||
|       return dbName; | ||||
|     }, | ||||
|     settings: getter('settings'), | ||||
|     computedSettings: (state, getters) => { | ||||
|       const customSettings = yaml.safeLoad(getters.settings); | ||||
|       const settings = yaml.safeLoad(defaultSettings); | ||||
|       const override = (obj, opt) => { | ||||
| @ -122,42 +164,16 @@ module.getters.computedSettings = (state, getters) => { | ||||
|         return obj; | ||||
|       }; | ||||
|       return override(settings, customSettings); | ||||
| }; | ||||
| module.actions.setSettings = setter('settings'); | ||||
| 
 | ||||
| // Templates
 | ||||
| module.getters.templates = getter('templates'); | ||||
| const makeAdditionalTemplate = (name, value, helpers = '\n') => ({ | ||||
|   name, | ||||
|   value, | ||||
|   helpers, | ||||
|   isAdditional: true, | ||||
| }); | ||||
| const additionalTemplates = { | ||||
|   plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'), | ||||
|   plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate), | ||||
|   styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate), | ||||
|   styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate), | ||||
|   jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), | ||||
| }; | ||||
| module.getters.allTemplates = (state, getters) => ({ | ||||
|     }, | ||||
|     localSettings: getter('localSettings'), | ||||
|     layoutSettings: getter('layoutSettings'), | ||||
|     templates: getter('templates'), | ||||
|     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) => { | ||||
|     }), | ||||
|     lastOpened: getter('lastOpened'), | ||||
|     lastOpenedIds: (state, getters, rootState) => { | ||||
|       const lastOpened = { | ||||
|         ...getters.lastOpened, | ||||
|       }; | ||||
| @ -169,83 +185,114 @@ module.getters.lastOpenedIds = (state, getters, rootState) => { | ||||
|         .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) => { | ||||
|     }, | ||||
|     syncData: getter('syncData'), | ||||
|     syncDataByItemId: (state, getters) => { | ||||
|       const result = {}; | ||||
|   const syncData = getters.syncData; | ||||
|   Object.keys(syncData).forEach((id) => { | ||||
|     const value = syncData[id]; | ||||
|       Object.entries(getters.syncData).forEach(([, value]) => { | ||||
|         result[value.itemId] = value; | ||||
|       }); | ||||
|       return result; | ||||
| }; | ||||
| module.getters.syncDataByType = (state, 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]; | ||||
|       Object.entries(getters.syncData).forEach(([, item]) => { | ||||
|         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) => { | ||||
|     }, | ||||
|     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]]; | ||||
| }; | ||||
| module.actions.patchTokens = patcher('tokens'); | ||||
| const tokenSetter = providerId => ({ getters, dispatch }, token) => { | ||||
|   dispatch('patchTokens', { | ||||
|     [providerId]: { | ||||
|       ...getters[`${providerId}Tokens`], | ||||
|       [token.sub]: 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'); | ||||
|   }, | ||||
|   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'); | ||||
| 
 | ||||
| export default module; | ||||
|       // Close explorer if not enough space
 | ||||
|       const patch = { | ||||
|         showSideBar: value === undefined ? !getters.layoutSettings.showSideBar : value, | ||||
|       }; | ||||
|       if (patch.showSideBar && notEnoughSpace(rootGetters)) { | ||||
|         patch.showExplorer = false; | ||||
|       } | ||||
|       dispatch('patchLayoutSettings', patch); | ||||
|     }, | ||||
|     toggleExplorer: ({ commit, getters, dispatch, rootGetters }, value) => { | ||||
|       // Close side bar if not enough space
 | ||||
|       const patch = { | ||||
|         showExplorer: value === undefined ? !getters.layoutSettings.showExplorer : value, | ||||
|       }; | ||||
|       if (patch.showExplorer && notEnoughSpace(rootGetters)) { | ||||
|         patch.showSideBar = false; | ||||
|       } | ||||
|       dispatch('patchLayoutSettings', patch); | ||||
|     }, | ||||
|     setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', { | ||||
|       sideBarPanel: value === undefined ? 'menu' : value, | ||||
|     }), | ||||
|     setTemplates: ({ commit }, data) => { | ||||
|       const dataToCommit = { | ||||
|         ...data, | ||||
|       }; | ||||
|       // We don't store additional templates
 | ||||
|       Object.keys(additionalTemplates).forEach((id) => { | ||||
|         delete dataToCommit[id]; | ||||
|       }); | ||||
|       commit('setItem', itemTemplate('templates', dataToCommit)); | ||||
|     }, | ||||
|     setLastOpenedId: ({ getters, commit, dispatch, rootState }, fileId) => { | ||||
|       const lastOpened = { ...getters.lastOpened }; | ||||
|       lastOpened[fileId] = Date.now(); | ||||
|       commit('setItem', itemTemplate('lastOpened', lastOpened)); | ||||
|       dispatch('cleanLastOpenedId'); | ||||
|     }, | ||||
|     cleanLastOpenedId: ({ getters, commit, rootState }) => { | ||||
|       const lastOpened = {}; | ||||
|       const oldLastOpened = getters.lastOpened; | ||||
|       Object.entries(oldLastOpened).forEach(([fileId, date]) => { | ||||
|         if (rootState.file.itemMap[fileId]) { | ||||
|           lastOpened[fileId] = date; | ||||
|         } | ||||
|       }); | ||||
|       commit('setItem', itemTemplate('lastOpened', lastOpened)); | ||||
|     }, | ||||
|     setSyncData: setter('syncData'), | ||||
|     patchSyncData: patcher('syncData'), | ||||
|     patchDataSyncData: patcher('dataSyncData'), | ||||
|     patchTokens: patcher('tokens'), | ||||
|     setGoogleToken: tokenSetter('google'), | ||||
|     setDropboxToken: tokenSetter('dropbox'), | ||||
|     setGithubToken: tokenSetter('github'), | ||||
|     setWordpressToken: tokenSetter('wordpress'), | ||||
|     setZendeskToken: tokenSetter('zendesk'), | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| @ -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()) | ||||
|         dispatch('modal/signInForComment', { | ||||
|           onResolve: () => googleHelper.signin() | ||||
|             .then(() => syncSvc.requestSync()) | ||||
|           .then(() => dispatch('createNewDiscussion', selection)) | ||||
|             .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
	 benweet
						benweet