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