diff --git a/src/components/App.vue b/src/components/App.vue index 52d41155..e9e1b7aa 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -14,6 +14,8 @@ import Layout from './Layout'; import Modal from './Modal'; import Notification from './Notification'; import SplashScreen from './SplashScreen'; +import timeSvc from '../services/timeSvc'; +import store from '../store'; // Global directives Vue.directive('focus', { @@ -48,6 +50,11 @@ Vue.directive('title', { }, }); +// Global filters +Vue.filter('formatTime', time => + // Access the minute counter for reactive refresh + timeSvc.format(time, store.state.minuteCounter)); + export default { components: { Layout, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 712631ae..7ad9dcd9 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -1,18 +1,22 @@ - diff --git a/src/components/UserName.vue b/src/components/UserName.vue new file mode 100644 index 00000000..e26a8a7f --- /dev/null +++ b/src/components/UserName.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/common/PreviewClassApplier.js b/src/components/common/PreviewClassApplier.js index 457ae42c..12019f1d 100644 --- a/src/components/common/PreviewClassApplier.js +++ b/src/components/common/PreviewClassApplier.js @@ -23,7 +23,9 @@ export default class PreviewClassApplier { this.lastEltCount = this.eltCollection.length; this.restoreClass = () => { - if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) { + if (!editorSvc.sectionDescWithDiffsList) { + this.removeClass(); + } else if (!this.eltCollection.length || this.eltCollection.length !== this.lastEltCount) { this.removeClass(); this.applyClass(); } diff --git a/src/components/common/app.scss b/src/components/common/app.scss index b2d4af3a..ba899ad8 100644 --- a/src/components/common/app.scss +++ b/src/components/common/app.scss @@ -71,9 +71,9 @@ textarea { height: auto; padding: 6px 12px; margin-bottom: 0; - font-size: 18px; font-weight: 400; line-height: 1.4; + text-transform: uppercase; overflow: hidden; text-align: center; white-space: nowrap; @@ -212,6 +212,12 @@ textarea { height: 100%; } +.gutter__background { + position: absolute; + height: 100%; + right: 0; +} + .new-discussion-button { color: rgba(0, 0, 0, 0.33); position: absolute; @@ -230,13 +236,13 @@ textarea { .discussion-editor-highlighting, .discussion-preview-highlighting { - background-color: mix(#fff, $selection-highlighting-color, 50%); + background-color: mix(#fff, $selection-highlighting-color, 70%); padding: 0.25em 0; } .discussion-editor-highlighting--hover, .discussion-preview-highlighting--hover { - background-color: mix(#fff, $selection-highlighting-color, 25%); + background-color: mix(#fff, $selection-highlighting-color, 50%); * { background-color: transparent; @@ -245,7 +251,7 @@ textarea { .discussion-editor-highlighting--selected, .discussion-preview-highlighting--selected { - background-color: mix(#fff, $selection-highlighting-color, 10%); + background-color: mix(#fff, $selection-highlighting-color, 20%); * { background-color: transparent; @@ -256,10 +262,6 @@ textarea { cursor: pointer; } -.discussion-preview-highlighting--selected { - cursor: auto; -} - .hidden-rendering-container { position: absolute; width: 500px; diff --git a/src/components/gutters/Comment.vue b/src/components/gutters/Comment.vue new file mode 100644 index 00000000..594020ce --- /dev/null +++ b/src/components/gutters/Comment.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/gutters/CommentList.vue b/src/components/gutters/CommentList.vue new file mode 100644 index 00000000..25f2dd0d --- /dev/null +++ b/src/components/gutters/CommentList.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/src/components/gutters/CurrentDiscussion.vue b/src/components/gutters/CurrentDiscussion.vue new file mode 100644 index 00000000..f957e0dc --- /dev/null +++ b/src/components/gutters/CurrentDiscussion.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/src/components/gutters/EditorNewDiscussionButtonGutter.vue b/src/components/gutters/EditorNewDiscussionButton.vue similarity index 72% rename from src/components/gutters/EditorNewDiscussionButtonGutter.vue rename to src/components/gutters/EditorNewDiscussionButton.vue index 6368196a..127d5e9b 100644 --- a/src/components/gutters/EditorNewDiscussionButtonGutter.vue +++ b/src/components/gutters/EditorNewDiscussionButton.vue @@ -1,24 +1,17 @@ diff --git a/src/components/gutters/PreviewNewDiscussionButton.vue b/src/components/gutters/PreviewNewDiscussionButton.vue new file mode 100644 index 00000000..9a3eb248 --- /dev/null +++ b/src/components/gutters/PreviewNewDiscussionButton.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/gutters/PreviewNewDiscussionButtonGutter.vue b/src/components/gutters/PreviewNewDiscussionButtonGutter.vue deleted file mode 100644 index fb1c1421..00000000 --- a/src/components/gutters/PreviewNewDiscussionButtonGutter.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - diff --git a/src/components/gutters/StickyComment.vue b/src/components/gutters/StickyComment.vue new file mode 100644 index 00000000..4ce94681 --- /dev/null +++ b/src/components/gutters/StickyComment.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/menus/common/MenuEntry.vue b/src/components/menus/common/MenuEntry.vue index 0448bdb3..a5d125e8 100644 --- a/src/components/menus/common/MenuEntry.vue +++ b/src/components/menus/common/MenuEntry.vue @@ -16,6 +16,8 @@ text-align: left; padding: 10px; height: auto; + font-size: 17px; + text-transform: none; div div { text-decoration: underline; diff --git a/src/components/modals/SponsorModal.vue b/src/components/modals/SponsorModal.vue index bf3b66f1..32974d99 100644 --- a/src/components/modals/SponsorModal.vue +++ b/src/components/modals/SponsorModal.vue @@ -76,12 +76,14 @@ export default { font-size: 2.3em; margin: 0.75rem 0; line-height: 1.2; + text-transform: none; span { display: inline-block; font-size: 0.75rem; opacity: 0.5; white-space: normal; + line-height: 1.5; } .paypal-option__offer { diff --git a/src/components/modals/TemplatesModal.vue b/src/components/modals/TemplatesModal.vue index 2c100631..3c6930c2 100644 --- a/src/components/modals/TemplatesModal.vue +++ b/src/components/modals/TemplatesModal.vue @@ -12,16 +12,16 @@
- - - -
diff --git a/src/components/modals/common/ModalInner.vue b/src/components/modals/common/ModalInner.vue index 9528b527..c4b1a9dd 100644 --- a/src/components/modals/common/ModalInner.vue +++ b/src/components/modals/common/ModalInner.vue @@ -24,17 +24,22 @@ export default { 'config', ]), showSponsorButton() { - return !this.$store.getters.isSponsor && this.$store.getters['modal/config'].type !== 'sponsor'; + const type = this.$store.getters['modal/config'].type; + return !this.$store.getters.isSponsor && type !== 'sponsor' && type !== 'signInForSponsorship'; }, }, methods: { sponsor() { Promise.resolve() .then(() => !this.$store.getters['data/loginToken'] && - this.$store.dispatch('modal/signInRequired') // If user has to sign in + this.$store.dispatch('modal/signInForSponsorship') // If user has to sign in .then(() => googleHelper.signin()) .then(() => syncSvc.requestSync())) - .then(() => this.$store.dispatch('modal/open', 'sponsor')) + .then(() => { + if (!this.$store.getters.isSponsor) { + this.$store.dispatch('modal/open', 'sponsor'); + } + }) .catch(() => { }); // Cancel }, }, @@ -65,7 +70,7 @@ export default { color: darken($error-color, 10%); background-color: transparentize($error-color, 0.85); border-radius: $border-radius-base; - padding: 1em; + padding: 1em 1.5em; margin-bottom: 1.2em; } diff --git a/src/libs/cleditHighlighter.js b/src/libs/cleditHighlighter.js index 476e1ccf..c0e05303 100644 --- a/src/libs/cleditHighlighter.js +++ b/src/libs/cleditHighlighter.js @@ -168,8 +168,10 @@ function Highlighter(editor) { } this.addTrailingNode() self.$trigger('highlighted') - editor.selectionMgr.restoreSelection() - editor.selectionMgr.updateCursorCoordinates() + if (editor.selectionMgr.hasFocus()) { + editor.selectionMgr.restoreSelection() + editor.selectionMgr.updateCursorCoordinates() + } }.cl_bind(this)) return sectionList diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js index 1967f17a..896b1276 100644 --- a/src/services/editorSvc.js +++ b/src/services/editorSvc.js @@ -11,6 +11,7 @@ import sectionUtils from './sectionUtils'; import extensionSvc from './extensionSvc'; import editorSvcDiscussions from './editorSvcDiscussions'; import editorSvcUtils from './editorSvcUtils'; +import utils from './utils'; import store from '../store'; const debounce = cledit.Utils.debounce; @@ -77,6 +78,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, * Initialize the cledit editor with markdown-it section parser and Prism highlighter */ initClEditor() { + this.sectionDescMeasuredList = null; + this.sectionDescWithDiffsList = null; const options = { sectionHighlighter: section => Prism.highlight( section.text, this.prismGrammars[section.data]), @@ -509,7 +512,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, if (content.properties !== lastProperties) { lastProperties = content.properties; const options = extensionSvc.getOptions(store.getters['content/currentProperties']); - if (JSON.stringify(options) !== JSON.stringify(this.options)) { + if (utils.serializeObject(options) !== utils.serializeObject(this.options)) { this.options = options; this.initPrism(); this.initConverter(); @@ -532,7 +535,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, immediate: true, }); - store.watch(() => store.getters['layout/styles'], + store.watch(() => utils.serializeObject(store.getters['layout/styles']), () => this.measureSectionDimensions(false, true)); this.initHighlighters(); diff --git a/src/services/editorSvcDiscussions.js b/src/services/editorSvcDiscussions.js index 7d045d58..0c6c1518 100644 --- a/src/services/editorSvcDiscussions.js +++ b/src/services/editorSvcDiscussions.js @@ -201,7 +201,7 @@ export default { Object.keys(discussions).forEach((discussionId) => { const classApplier = oldEditorClassAppliers[discussionId] || new EditorClassApplier( () => { - const classes = [`discussion-editor-highlighting-${discussionId}`, 'discussion-editor-highlighting']; + const classes = [`discussion-editor-highlighting--${discussionId}`, 'discussion-editor-highlighting']; if (store.state.discussion.currentDiscussionId === discussionId) { classes.push('discussion-editor-highlighting--selected'); } @@ -210,7 +210,7 @@ export default { () => { const startMarker = discussionMarkers[`${discussionId}:start`]; const endMarker = discussionMarkers[`${discussionId}:end`]; - return { + return startMarker && endMarker && { start: startMarker.offset, end: endMarker.offset, }; @@ -229,7 +229,7 @@ export default { Object.keys(discussions).forEach((discussionId) => { const classApplier = oldPreviewClassAppliers[discussionId] || new PreviewClassApplier( () => { - const classes = [`discussion-preview-highlighting-${discussionId}`, 'discussion-preview-highlighting']; + const classes = [`discussion-preview-highlighting--${discussionId}`, 'discussion-preview-highlighting']; if (store.state.discussion.currentDiscussionId === discussionId) { classes.push('discussion-preview-highlighting--selected'); } @@ -238,7 +238,7 @@ export default { () => { const startMarker = discussionMarkers[`${discussionId}:start`]; const endMarker = discussionMarkers[`${discussionId}:end`]; - return { + return startMarker && endMarker && { start: this.getPreviewOffset(startMarker.offset), end: this.getPreviewOffset(endMarker.offset), }; diff --git a/src/services/editorSvcUtils.js b/src/services/editorSvcUtils.js index 07d5da8f..a22814b2 100644 --- a/src/services/editorSvcUtils.js +++ b/src/services/editorSvcUtils.js @@ -1,4 +1,5 @@ import DiffMatchPatch from 'diff-match-patch'; +import cledit from '../libs/cledit'; import animationSvc from './animationSvc'; import store from '../store'; @@ -105,6 +106,24 @@ export default { return editorOffset; }, + /** + * Get the coordinates of an offset in the preview + */ + getPreviewOffsetCoordinates(offset) { + const start = cledit.Utils.findContainer(this.previewElt, offset && offset - 1); + const end = cledit.Utils.findContainer(this.previewElt, offset || offset + 1); + const range = document.createRange(); + range.setStart(start.container, start.offsetInContainer); + range.setEnd(end.container, end.offsetInContainer); + const rect = range.getBoundingClientRect(); + const contentRect = this.previewElt.getBoundingClientRect(); + return { + top: Math.round((rect.top - contentRect.top) + this.previewElt.scrollTop), + height: Math.round(rect.height), + left: Math.round((rect.right - contentRect.left) + this.previewElt.scrollLeft), + }; + }, + /** * Scroll the preview (or the editor if preview is hidden) to the specified anchor */ diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js index 1f7b4159..06ceb168 100644 --- a/src/services/localDbSvc.js +++ b/src/services/localDbSvc.js @@ -380,6 +380,7 @@ localDbSvc.sync() // Wait for the next watch tick return null; } + return Promise.resolve() // Load contentState from DB .then(() => localDbSvc.loadContentState(currentFile.id)) @@ -388,8 +389,15 @@ localDbSvc.sync() // Load content from DB .then(() => localDbSvc.loadItem(`${currentFile.id}/content`)) .then( - // Success, set last opened file - () => store.dispatch('data/setLastOpenedId', currentFile.id), + () => { + // 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']; diff --git a/src/services/networkSvc.js b/src/services/networkSvc.js index 91e1f3c1..b42de77a 100644 --- a/src/services/networkSvc.js +++ b/src/services/networkSvc.js @@ -88,6 +88,7 @@ export default { .then(() => { const data = utils.parseQueryParams(`${event.data}`.slice(1)); if (data.error || data.state !== state) { + console.error(data); // eslint-disable-line no-console reject('Could not get required authorization.'); } else { resolve({ diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js index fb711385..94d285f3 100644 --- a/src/services/optional/scrollSync.js +++ b/src/services/optional/scrollSync.js @@ -166,10 +166,10 @@ editorSvc.$on('previewText', () => { }); store.watch( - () => store.getters['layout/styles'], - (styles) => { - isScrollEditor = styles.showEditor; - isScrollPreview = !styles.showEditor; + () => store.getters['layout/styles'].showEditor, + (showEditor) => { + isScrollEditor = showEditor; + isScrollPreview = !showEditor; skipAnimation = true; }); diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index 545ad6dc..e2cdc3b7 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -116,6 +116,7 @@ export default { .then((res) => { store.commit('userInfo/addItem', { id: res.body.id, + name: res.body.displayName, imageUrl: res.body.image.url, }); return res.body; diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index 443de5a0..197829f6 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -155,11 +155,19 @@ function syncFile(fileId, syncContext = new SyncContext()) { }; const isWelcomeFile = () => { + if (store.getters['data/syncDataByItemId'][`${fileId}/content`]) { + // If file has already been synced, keep on syncing + return false; + } const file = getFile(); const content = getContent(); + if (!file || !content) { + return false; + } const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes; - const hash = content ? utils.hash(content.text) : 0; - return file.name === 'Welcome file' && welcomeFileHashes[hash]; + const hash = utils.hash(content.text); + const hasDiscussions = Object.keys(content.discussions).length; + return file.name === 'Welcome file' && welcomeFileHashes[hash] && !hasDiscussions; }; const syncOneContentLocation = () => { @@ -180,8 +188,8 @@ function syncFile(fileId, syncContext = new SyncContext()) { // Skip welcome file if not synchronized explicitly (syncLocations.length > 1 || !isWelcomeFile()) ) { - const token = provider.getToken(syncLocation); - result = provider && token && store.dispatch('queue/doWithLocation', { + const token = provider && provider.getToken(syncLocation); + result = token && store.dispatch('queue/doWithLocation', { location: syncLocation, promise: provider.downloadContent(token, syncLocation) .then((serverContent = null) => { diff --git a/src/services/timeSvc.js b/src/services/timeSvc.js new file mode 100644 index 00000000..8b157ed2 --- /dev/null +++ b/src/services/timeSvc.js @@ -0,0 +1,173 @@ +// Credit: https://github.com/github/time-elements/ +const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +const pad = num => `0${num}`.slice(-2); + +function strftime(time, formatString) { + const day = time.getDay(); + const date = time.getDate(); + const month = time.getMonth(); + const year = time.getFullYear(); + const hour = time.getHours(); + const minute = time.getMinutes(); + const second = time.getSeconds(); + return formatString.replace(/%([%aAbBcdeHIlmMpPSwyYZz])/g, (_arg) => { + let match; + const modifier = _arg[1]; + switch (modifier) { + case '%': + default: + return '%'; + case 'a': + return weekdays[day].slice(0, 3); + case 'A': + return weekdays[day]; + case 'b': + return months[month].slice(0, 3); + case 'B': + return months[month]; + case 'c': + return time.toString(); + case 'd': + return pad(date); + case 'e': + return date; + case 'H': + return pad(hour); + case 'I': + return pad(strftime(time, '%l')); + case 'l': + return hour === 0 || hour === 12 ? 12 : (hour + 12) % 12; + case 'm': + return pad(month + 1); + case 'M': + return pad(minute); + case 'p': + return hour > 11 ? 'PM' : 'AM'; + case 'P': + return hour > 11 ? 'pm' : 'am'; + case 'S': + return pad(second); + case 'w': + return day; + case 'y': + return pad(year % 100); + case 'Y': + return year; + case 'Z': + match = time.toString().match(/\((\w+)\)$/); + return match ? match[1] : ''; + case 'z': + match = time.toString().match(/\w([+-]\d\d\d\d) /); + return match ? match[1] : ''; + } + }); +} + +let dayFirst = null; +let yearSeparator = null; + +// Private: Determine if the day should be formatted before the month name in +// the user's current locale. For example, `9 Jun` for en-GB and `Jun 9` +// for en-US. +// +// Returns true if the day appears before the month. +function isDayFirst() { + if (dayFirst !== null) { + return dayFirst; + } + + if (!('Intl' in window)) { + return false; + } + + const options = { day: 'numeric', month: 'short' }; + const formatter = new window.Intl.DateTimeFormat(undefined, options); + const output = formatter.format(new Date(0)); + + dayFirst = !!output.match(/^\d/); + return dayFirst; +} + +// Private: Determine if the year should be separated from the month and day +// with a comma. For example, `9 Jun 2014` in en-GB and `Jun 9, 2014` in en-US. +// +// Returns true if the date needs a separator. +function isYearSeparator() { + if (yearSeparator !== null) { + return yearSeparator; + } + + if (!('Intl' in window)) { + return true; + } + + const options = { day: 'numeric', month: 'short', year: 'numeric' }; + const formatter = new window.Intl.DateTimeFormat(undefined, options); + const output = formatter.format(new Date(0)); + + yearSeparator = !!output.match(/\d,/); + return yearSeparator; +} + +// Private: Determine if the date occurs in the same year as today's date. +// +// date - The Date to test. +// +// Returns true if it's this year. +function isThisYear(date) { + const now = new Date(); + return now.getUTCFullYear() === date.getUTCFullYear(); +} + +class RelativeTime { + constructor(date) { + this.date = date; + } + + toString() { + const ago = this.timeElapsed(); + return ago || `on ${this.formatDate()}`; + } + + timeElapsed() { + const ms = new Date().getTime() - this.date.getTime(); + const sec = Math.round(ms / 1000); + const min = Math.round(sec / 60); + const hr = Math.round(min / 60); + const day = Math.round(hr / 24); + if (ms < 0) { + return 'just now'; + } else if (sec < 45) { + return 'just now'; + } else if (sec < 90) { + return 'a minute ago'; + } else if (min < 45) { + return `${min} minutes ago`; + } else if (min < 90) { + return 'an hour ago'; + } else if (hr < 24) { + return `${hr} hours ago`; + } else if (hr < 36) { + return 'a day ago'; + } else if (day < 30) { + return `${day} days ago`; + } + return null; + } + + formatDate() { + let format = isDayFirst() ? '%e %b' : '%b %e'; + if (!isThisYear(this.date)) { + format += isYearSeparator() ? ', %Y' : ' %Y'; + } + return strftime(this.date, format); + } +} + +export default { + format(time) { + return time && new RelativeTime(new Date(time)).toString(); + }, +}; diff --git a/src/services/userSvc.js b/src/services/userSvc.js new file mode 100644 index 00000000..a8478588 --- /dev/null +++ b/src/services/userSvc.js @@ -0,0 +1,28 @@ +import googleHelper from '../services/providers/helpers/googleHelper'; +import store from '../store'; + +const promised = {}; + +export default { + getInfo(userId) { + if (!promised[userId]) { + // Try to find a token with this sub + const token = store.getters['data/googleTokens'][userId]; + if (token) { + store.commit('userInfo/addItem', { + id: userId, + name: token.name, + }); + } + + // Get user info from Google + if (!store.state.offline) { + promised[userId] = true; + googleHelper.getUser(userId) + .catch(() => { + promised[userId] = false; + }); + } + } + }, +}; diff --git a/src/store/data.js b/src/store/data.js index 556d3e72..597a78fc 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -64,14 +64,16 @@ module.actions.toggleSidePreview = localSettingsToggler('showSidePreview'); module.actions.toggleStatusBar = localSettingsToggler('showStatusBar'); module.actions.toggleScrollSync = localSettingsToggler('scrollSync'); module.actions.toggleFocusMode = localSettingsToggler('focusMode'); -const notEnoughSpace = (rootGetters) => { - const constants = rootGetters['layout/constants']; +const notEnoughSpace = (getters) => { + const constants = getters['layout/constants']; + const showGutter = getters['discussion/currentDiscussion']; return document.body.clientWidth < constants.editorMinWidth + constants.explorerWidth + constants.sideBarWidth + - constants.buttonBarWidth; + constants.buttonBarWidth + + (showGutter ? constants.gutterWidth : 0); }; -module.actions.toggleSideBar = ({ getters, dispatch, rootGetters }, value) => { +module.actions.toggleSideBar = ({ commit, getters, dispatch, rootGetters }, value) => { // Reset side bar dispatch('setSideBarPanel'); // Close explorer if not enough space @@ -83,7 +85,7 @@ module.actions.toggleSideBar = ({ getters, dispatch, rootGetters }, value) => { } dispatch('patchLocalSettings', patch); }; -module.actions.toggleExplorer = ({ getters, dispatch, rootGetters }, value) => { +module.actions.toggleExplorer = ({ commit, getters, dispatch, rootGetters }, value) => { // Close side bar if not enough space const patch = { showExplorer: value === undefined ? !getters.localSettings.showExplorer : value, diff --git a/src/store/discussion.js b/src/store/discussion.js index 96bf7e82..3abacbb2 100644 --- a/src/store/discussion.js +++ b/src/store/discussion.js @@ -1,51 +1,148 @@ import utils from '../services/utils'; +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: { currentDiscussionId: null, newDiscussion: null, - newDiscussionId: '', + newDiscussionId: null, + newCommentText: '', + newCommentSelection: { start: 0, end: 0 }, + isCommenting: false, + stickyComment: null, }, mutations: { setCurrentDiscussionId: (state, value) => { - state.currentDiscussionId = value; + if (state.currentDiscussionId !== value) { + state.currentDiscussionId = value; + state.isCommenting = false; + } }, setNewDiscussion: (state, value) => { state.newDiscussion = value; state.newDiscussionId = utils.uid(); state.currentDiscussionId = state.newDiscussionId; + state.isCommenting = true; }, patchNewDiscussion: (state, value) => { Object.assign(state.newDiscussion, value); }, + setNewCommentText: (state, value) => { + state.newCommentText = value || ''; + }, + setNewCommentSelection: (state, value) => { + state.newCommentSelection = value; + }, + setIsCommenting: (state, value) => { + state.isCommenting = value; + if (!value) { + state.newDiscussionId = null; + } + }, + setStickyComment: (state, value) => { + state.stickyComment = value; + }, }, getters: { newDiscussion: state => state.currentDiscussionId === state.newDiscussionId && state.newDiscussion, + currentFileDiscussionLastComments: (state, getters, rootState, rootGetters) => { + const discussions = rootGetters['content/current'].discussions; + const comments = rootGetters['content/current'].comments; + const discussionLastComments = {}; + Object.keys(comments).forEach((commentId) => { + const comment = comments[commentId]; + if (discussions[comment.discussionId]) { + const lastComment = discussionLastComments[comment.discussionId]; + if (!lastComment || lastComment.created < comment.created) { + discussionLastComments[comment.discussionId] = comment; + } + } + }); + return discussionLastComments; + }, currentFileDiscussions: (state, getters, rootState, rootGetters) => { - const currentContent = rootGetters['content/current']; - const currentDiscussions = { - ...currentContent.discussions, - }; + const currentFileDiscussions = {}; const newDiscussion = getters.newDiscussion; if (newDiscussion) { - currentDiscussions[state.newDiscussionId] = newDiscussion; + currentFileDiscussions[state.newDiscussionId] = newDiscussion; } - return currentDiscussions; + const discussions = rootGetters['content/current'].discussions; + const discussionLastComments = getters.currentFileDiscussionLastComments; + Object.keys(discussionLastComments) + .sort((id1, id2) => + discussionLastComments[id2].created - discussionLastComments[id1].created) + .forEach((discussionId) => { + currentFileDiscussions[discussionId] = discussions[discussionId]; + }); + return currentFileDiscussions; }, currentDiscussion: (state, getters) => getters.currentFileDiscussions[state.currentDiscussionId], + previousDiscussionId: idShifter(-1), + nextDiscussionId: idShifter(1), + currentDiscussionComments: (state, getters, rootState, rootGetters) => { + const comments = {}; + if (getters.currentDiscussion) { + const contentComments = rootGetters['content/current'].comments; + Object.keys(contentComments) + .filter(commentId => + contentComments[commentId].discussionId === state.currentDiscussionId) + .sort((id1, id2) => + contentComments[id1].created - contentComments[id2].created) + .forEach((commentId) => { + comments[commentId] = contentComments[commentId]; + }); + } + return comments; + }, + currentDiscussionLastCommentId: (state, getters) => + Object.keys(getters.currentDiscussionComments).pop(), + currentDiscussionLastComment: (state, getters) => + getters.currentDiscussionComments[getters.currentDiscussionLastCommentId], }, actions: { createNewDiscussion({ commit, rootGetters }, selection) { if (selection) { let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); - if (text.length > 250) { - text = `${text.slice(0, 249).trim()}…`; + const maxLength = 80; + if (text.length > maxLength) { + text = `${text.slice(0, maxLength - 1).trim()}…`; } commit('setNewDiscussion', { ...selection, text }); } }, + cleanCurrentFile( + { getters, rootGetters, commit, dispatch }, + { filterComment, filterDiscussion } = {}, + ) { + const discussions = rootGetters['content/current'].discussions; + const comments = rootGetters['content/current'].comments; + const patch = { + discussions: {}, + comments: {}, + }; + Object.keys(comments).forEach((commentId) => { + const comment = comments[commentId]; + const discussion = discussions[comment.discussionId]; + if (discussion && comment !== filterComment && discussion !== filterDiscussion) { + patch.discussions[comment.discussionId] = discussion; + patch.comments[commentId] = comment; + } + }); + + const nextDiscussionId = getters.nextDiscussionId; + dispatch('content/patchCurrent', patch, { root: true }); + if (!getters.currentDiscussion) { + // Keep the gutter open + commit('setCurrentDiscussionId', nextDiscussionId); + } + }, }, }; diff --git a/src/store/index.js b/src/store/index.js index a10e8fca..127468c5 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -46,6 +46,7 @@ const store = new Vuex.Store({ ready: false, offline: false, lastOfflineCheck: 0, + minuteCounter: 0, monetizeSponsor: false, }, getters: { @@ -69,6 +70,9 @@ const store = new Vuex.Store({ updateLastOfflineCheck: (state) => { state.lastOfflineCheck = Date.now(); }, + updateMinuteCounter: (state) => { + state.minuteCounter += 1; + }, setMonetizeSponsor: (state, value) => { state.monetizeSponsor = value; }, @@ -121,4 +125,8 @@ const store = new Vuex.Store({ plugins: debug ? [createLogger()] : [], }); +setInterval(() => { + store.commit('updateMinuteCounter'); +}, 60 * 1000); + export default store; diff --git a/src/store/layout.js b/src/store/layout.js index 0032dfc7..a3107bde 100644 --- a/src/store/layout.js +++ b/src/store/layout.js @@ -1,7 +1,5 @@ const minPadding = 20; -const previewButtonWidth = 55; const editorTopPadding = 10; -const gutterWidth = 250; const navigationBarEditButtonsWidth = (36 * 14) + 8; // 14 buttons + 1 spacer const navigationBarLeftButtonWidth = 38 + 4 + 15; const navigationBarRightButtonWidth = 38 + 8; @@ -15,6 +13,7 @@ const minTitleMaxWidth = 200; const constants = { editorMinWidth: 320, explorerWidth: 250, + gutterWidth: 250, sideBarWidth: 280, navigationBarHeight: 44, buttonBarWidth: 26, @@ -48,9 +47,9 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin } let doublePanelWidth = styles.innerWidth - constants.buttonBarWidth; - const showGutter = getters['discussion/currentDiscussion']; + const showGutter = !!getters['discussion/currentDiscussion']; if (showGutter) { - doublePanelWidth -= gutterWidth; + doublePanelWidth -= constants.gutterWidth; } if (doublePanelWidth < constants.editorMinWidth) { doublePanelWidth = constants.editorMinWidth; @@ -87,17 +86,17 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin const panelWidth = Math.floor(doublePanelWidth / 2); styles.previewWidth = styles.showSidePreview ? panelWidth : - doublePanelWidth + constants.buttonBarWidth; - let previewRightPadding = Math.max( + doublePanelWidth; + const previewRightPadding = Math.max( Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding); + if (!styles.showSidePreview) { + styles.previewWidth += constants.buttonBarWidth; + } styles.previewGutterWidth = showGutter && !localSettings.showEditor - ? gutterWidth + ? constants.gutterWidth : 0; const previewLeftPadding = previewRightPadding + styles.previewGutterWidth; styles.previewGutterLeft = previewLeftPadding - minPadding; - if (!styles.showEditor && previewRightPadding < previewButtonWidth) { - previewRightPadding = previewButtonWidth; - } styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`; styles.editorWidth = styles.showSidePreview ? panelWidth : @@ -105,7 +104,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin const editorRightPadding = Math.max( Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); styles.editorGutterWidth = showGutter && localSettings.showEditor - ? gutterWidth + ? constants.gutterWidth : 0; const editorLeftPadding = editorRightPadding + styles.editorGutterWidth; styles.editorGutterLeft = editorLeftPadding - minPadding; diff --git a/src/store/modal.js b/src/store/modal.js index aa11df57..7db38c2c 100644 --- a/src/store/modal.js +++ b/src/store/modal.js @@ -56,6 +56,16 @@ export default { resolveText: 'Yes, delete', rejectText: 'No', }), + discussionDeletion: ({ dispatch }) => dispatch('open', { + content: '

You are about to delete a discussion. Are you sure?

', + resolveText: 'Yes, delete', + rejectText: 'No', + }), + commentDeletion: ({ dispatch }) => dispatch('open', { + content: '

You are about to delete a comment. Are you sure?

', + resolveText: 'Yes, delete', + rejectText: 'No', + }), trashDeletion: ({ dispatch }) => dispatch('open', { content: '

Files in the trash are automatically deleted after 7 days of inactivity.

', resolveText: 'Ok', @@ -67,14 +77,15 @@ export default { }), providerRedirection: ({ dispatch }, { providerName, onResolve }) => dispatch('open', { content: `

You are about to navigate to the ${providerName} authorization page.

`, - resolveText: 'Ok, go on!', + resolveText: 'Ok, go on', rejectText: 'Cancel', onResolve, }), - signInRequired: ({ dispatch }) => dispatch('open', { - content: `

We have to sign you in with Google in order to activate your sponsorship.

- `, - resolveText: 'Ok, sign in!', + signInForSponsorship: ({ dispatch }) => dispatch('open', { + type: 'signInForSponsorship', + content: `

You have to sign in with Google to enable your sponsorship.

+ `, + resolveText: 'Ok, sign in', rejectText: 'Cancel', }), sponsorOnly: ({ dispatch }) => dispatch('open', {