diff options
author | Max <max@nextcloud.com> | 2022-07-08 19:47:29 +0300 |
---|---|---|
committer | Max <max@nextcloud.com> | 2022-07-29 15:15:08 +0300 |
commit | 17146ed0fd8e2a7e66129280914081f632aebf20 (patch) | |
tree | 80c042e88c6eec9b311e2c00c64cc44254f2cb66 /src/components/Editor | |
parent | cfa8c30de026084e4b13ba94d3c4edf3fff7ee28 (diff) |
refactor: add Status and DocumentStatus components
`Editor/Status` handles the status display in the menubar.
`Editor/DocumentStatus` handles the document messages on conflict etc.
Also move all files that are only used within it into the `Components/Editor` dir.
Signed-off-by: Max <max@nextcloud.com>
Diffstat (limited to 'src/components/Editor')
-rw-r--r-- | src/components/Editor/AvatarWrapper.vue | 68 | ||||
-rw-r--r-- | src/components/Editor/DocumentStatus.vue | 90 | ||||
-rw-r--r-- | src/components/Editor/GuestNameDialog.vue | 117 | ||||
-rw-r--r-- | src/components/Editor/SessionList.vue | 200 | ||||
-rw-r--r-- | src/components/Editor/Status.vue | 198 |
5 files changed, 673 insertions, 0 deletions
diff --git a/src/components/Editor/AvatarWrapper.vue b/src/components/Editor/AvatarWrapper.vue new file mode 100644 index 000000000..749c580d3 --- /dev/null +++ b/src/components/Editor/AvatarWrapper.vue @@ -0,0 +1,68 @@ +<template> + <div class="avatar-wrapper" :style="sessionAvatarStyle"> + <Avatar v-if="session.userId" + :user="session.userId ? session.userId : session.guestName" + :is-guest="session.userId === null" + :disable-menu="true" + :show-user-status="false" + :disable-tooltip="true" + :size="size" /> + <div v-else class="avatar" :style="sessionBackgroundStyle"> + {{ guestInitial }} + </div> + </div> +</template> + +<script> +import Avatar from '@nextcloud/vue/dist/Components/Avatar' +export default { + name: 'AvatarWrapper', + components: { + Avatar, + }, + props: { + session: { + type: Object, + required: true, + }, + size: { + type: Number, + default: () => 32, + }, + }, + computed: { + sessionAvatarStyle() { + return { + ...this.sessionBackgroundStyle, + 'border-color': this.session.color, + 'border-width': '2px', + 'border-style': 'solid', + '--size': this.size + 'px', + '--font-size': this.size / 2 + 'px', + } + }, + sessionBackgroundStyle() { + return { + 'background-color': this.session.userId ? this.session.color + ' !important' : '#b9b9b9', + } + }, + guestInitial() { + return this.session.guestName === '' ? '?' : this.session.guestName.slice(0, 1).toUpperCase() + }, + }, +} +</script> + +<style lang="scss" scoped> + +.avatar, .avatar-wrapper { + border-radius: 50%; + width: var(--size); + height: var(--size); + text-align: center; + color: #ffffff; + line-height: var(--size); + font-size: var(--font-size); + font-weight: normal; +} +</style> diff --git a/src/components/Editor/DocumentStatus.vue b/src/components/Editor/DocumentStatus.vue new file mode 100644 index 000000000..d7833e4aa --- /dev/null +++ b/src/components/Editor/DocumentStatus.vue @@ -0,0 +1,90 @@ +<!-- + - @copyright Copyright (c) 2022 Max <max@nextcloud.com> + - + - @author Max <max@nextcloud.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div class="document-status"> + <p v-if="idle" class="msg"> + {{ t('text', 'Document idle for {timeout} minutes, click to continue editing', { timeout: IDLE_TIMEOUT }) }} <a class="button primary" @click="reconnect">{{ t('text', 'Reconnect') }}</a> + </p> + <p v-else-if="hasSyncCollission" class="msg icon-error"> + {{ t('text', 'The document has been changed outside of the editor. The changes cannot be applied.') }} + </p> + <p v-else-if="hasConnectionIssue" class="msg"> + {{ t('text', 'File could not be loaded. Please check your internet connection.') }} <a class="button primary" @click="reconnect">{{ t('text', 'Reconnect') }}</a> + </p> + <p v-if="lock" class="msg msg-locked"> + <Lock /> {{ t('text', 'This file is opened read-only as it is currently locked by {user}.', { user: lock.displayName }) }} + </p> + </div> +</template> + +<script> + +import { ERROR_TYPE, IDLE_TIMEOUT } from './../../services/SyncService.js' +import Lock from 'vue-material-design-icons/Lock' + +export default { + name: 'DocumentStatus', + + components: { + Lock, + }, + + props: { + idle: { + type: Boolean, + require: true, + }, + lock: { + type: Object, + default: null, + }, + syncError: { + type: Object, + default: null, + }, + hasConnectionIssue: { + type: Boolean, + require: true, + }, + }, + + data() { + return { + IDLE_TIMEOUT, + } + }, + + computed: { + hasSyncCollission() { + return this.syncError && this.syncError.type === ERROR_TYPE.SAVE_COLLISSION + }, + }, + + methods: { + reconnect() { + this.$emit('reconnect') + }, + }, + +} +</script> diff --git a/src/components/Editor/GuestNameDialog.vue b/src/components/Editor/GuestNameDialog.vue new file mode 100644 index 000000000..115206317 --- /dev/null +++ b/src/components/Editor/GuestNameDialog.vue @@ -0,0 +1,117 @@ +<!-- + - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <form v-tooltip="t('text', 'Enter your name so other people can see who is editing')" class="guest-name-dialog" @submit.prevent="setGuestName()"> + <label><AvatarWrapper :session="session" :size="32" /></label> + <input v-model="guestName" + type="text" + :aria-label="t('text', 'Edit guest name')" + :placeholder="t('text', 'Guest')"> + <input type="submit" + class="icon-confirm" + :aria-label="t('text', 'Save guest name')" + value=""> + </form> +</template> + +<script> +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import { generateUrl } from '@nextcloud/router' +import AvatarWrapper from './AvatarWrapper.vue' +import { useSyncServiceMixin } from '../EditorWrapper.provider.js' + +export default { + name: 'GuestNameDialog', + components: { + AvatarWrapper, + }, + directives: { + tooltip: Tooltip, + }, + mixins: [ + useSyncServiceMixin, + ], + props: { + session: { + type: Object, + required: true, + }, + }, + data() { + return { + guestName: '', + guestNameBuffered: '', + } + }, + computed: { + avatarUrl() { + const size = 32 + const avatarUrl = generateUrl( + '/avatar/guest/{user}/{size}', + { + user: this.guestNameBuffered, + size, + }) + return window.location.protocol + '//' + window.location.host + avatarUrl + }, + }, + beforeMount() { + this.guestName = this.$syncService.session.guestName + this.updateBufferedGuestName() + }, + methods: { + setGuestName() { + const previousGuestName = this.$syncService.session.guestName + this.$syncService.updateSession(this.guestName).then(() => { + localStorage.setItem('nick', this.guestName) + this.updateBufferedGuestName() + }).catch((e) => { + this.guestName = previousGuestName + }) + }, + updateBufferedGuestName() { + this.guestNameBuffered = this.guestName + }, + }, +} +</script> + +<style scoped lang="scss"> + form.guest-name-dialog { + display: flex; + align-items: center; + padding: 6px; + + &::v-deep(img) { + margin: 0 !important; + } + + input[type=text] { + flex-grow: 1; + } + label { + padding-right: 3px; + height: 32px; + } + } +</style> diff --git a/src/components/Editor/SessionList.vue b/src/components/Editor/SessionList.vue new file mode 100644 index 000000000..8f808ec78 --- /dev/null +++ b/src/components/Editor/SessionList.vue @@ -0,0 +1,200 @@ +<!-- + - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <Popover class="session-list" placement="bottom"> + <button slot="trigger" + v-tooltip.bottom="t('text', 'Active people')" + class="avatar-list"> + <div class="avatardiv icon-group" /> + <AvatarWrapper v-for="session in sessionsVisible" + :key="session.id" + :session="session" + :size="40" /> + </button> + <template #default> + <div class="session-menu"> + <slot name="lastSaved" /> + <ul> + <slot /> + <li v-for="session in participantsPopover" + :key="session.id" + :style="avatarStyle(session)"> + <AvatarWrapper :session="session" :size="36" /> + <span class="session-label"> + {{ session.userId ? session.displayName : (session.guestName ? session.guestName : t('text', 'Guest')) }} + </span> + <span v-if="session.userId === null" class="guest-label">({{ t('text', 'guest') }})</span> + </li> + </ul> + <input id="toggle-color-annotations" + v-model="showAuthorAnnotations" + type="checkbox" + class="checkbox"> + <label for="toggle-color-annotations">{{ t('text', 'Show author colors') }}</label> + <p class="hint"> + {{ t('text', 'Author colors are only shown until everyone has closed the document.') }} + </p> + </div> + </template> + </Popover> +</template> + +<script> +import Popover from '@nextcloud/vue/dist/Components/Popover' +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import AvatarWrapper from './AvatarWrapper.vue' +import store from '../../mixins/store.js' + +const COLLABORATOR_IDLE_TIME = 60 +const COLLABORATOR_DISCONNECT_TIME = 90 + +export default { + name: 'SessionList', + components: { + AvatarWrapper, + Popover, + }, + directives: { + tooltip: Tooltip, + }, + mixins: [store], + props: { + sessions: { + type: Object, + default: () => { return {} }, + }, + }, + data() { + return { + myName: '', + } + }, + computed: { + showAuthorAnnotations: { + get() { + return this.$store.state.showAuthorAnnotations + }, + set(value) { + this.$store.dispatch('setShowAuthorAnnotations', value) + }, + }, + participantsPopover() { + if (this.currentSession.guestName) { + return this.participantsWithoutCurrent + } + return this.participants + }, + participantsWithoutCurrent() { + return this.participants.filter((session) => !session.isCurrent) + }, + participants() { + return Object.values(this.sessions).filter((session) => + session.lastContact > Date.now() / 1000 - COLLABORATOR_DISCONNECT_TIME + && (session.userId !== null || session.guestName !== null) + ).sort((a, b) => a.lastContact < b.lastContact) + }, + currentSession() { + return Object.values(this.sessions).find((session) => session.isCurrent) + }, + avatarStyle() { + return (session) => { + return { + opacity: session.lastContact > Date.now() / 1000 - COLLABORATOR_IDLE_TIME ? 1 : 0.5, + } + } + }, + sessionsVisible() { + return this.participantsWithoutCurrent.slice(0, 3) + }, + }, +} +</script> + +<style scoped lang="scss"> + .avatar-list { + border: none; + background-color: var(--color-main-background); + padding: 0; + margin: 0; + padding-left: 6px; + display: inline-flex; + flex-direction: row-reverse; + + &:focus { + background-color: #eee; + } + + .avatar-wrapper { + margin: 0 -18px 0 0; + z-index: 1; + border-radius: 50%; + overflow: hidden; + box-sizing: content-box !important; + height: 36px; + width: 36px; + } + + .icon-more, .icon-group, .icon-settings-dark { + background-color: var(--color-background-dark); + width: 40px; + height: 40px; + margin: 0 6px 0 0; + } + } + + .session-menu { + max-width: 280px; + padding-top: 6px; + padding-bottom: 6px; + + ul li { + align-items: center; + display: flex; + padding: 6px; + + .avatar-wrapper { + height: 36px; + width: 36px; + margin-right: 6px; + } + + .session-label { + padding-right: 3px; + } + .guest-label { + padding-left: 3px; + color: var(--color-text-maxcontrast); + } + } + } + + label { + display: block; + margin: 8px; + } + + .hint { + margin: 8px; + color: var(--color-text-maxcontrast); + } +</style> diff --git a/src/components/Editor/Status.vue b/src/components/Editor/Status.vue new file mode 100644 index 000000000..6536cf533 --- /dev/null +++ b/src/components/Editor/Status.vue @@ -0,0 +1,198 @@ +<!-- + - @copyright Copyright (c) 2022 Max <max@nextcloud.com> + - + - @author Max <max@nextcloud.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div class="text-editor__session-list"> + <div v-if="$isMobile" v-tooltip="lastSavedStatusTooltip" :class="saveStatusClass" /> + <div v-else + v-tooltip="lastSavedStatusTooltip" + class="save-status" + :aria-label="t('text', 'Document save status')" + :class="lastSavedStatusClass"> + {{ lastSavedStatus }} + </div> + <SessionList :sessions="sessions"> + <p slot="lastSaved" class="last-saved"> + {{ t('text', 'Last saved') }}: {{ lastSavedString }} + </p> + <GuestNameDialog v-if="$isPublic && !currentSession.userId" :session="currentSession" /> + </SessionList> + </div> +</template> + +<script> + +import { ERROR_TYPE } from './../../services/SyncService.js' +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import { + useIsMobileMixin, + useIsPublicMixin, +} from '../EditorWrapper.provider.js' + +export default { + name: 'Status', + + components: { + SessionList: () => import(/* webpackChunkName: "editor-collab" */'./SessionList.vue'), + GuestNameDialog: () => import(/* webpackChunkName: "editor-guest" */'./GuestNameDialog.vue'), + }, + + directives: { + Tooltip, + }, + + mixins: [useIsMobileMixin, useIsPublicMixin], + + props: { + hasConnectionIssue: { + type: Boolean, + require: true, + }, + dirty: { + type: Boolean, + require: true, + }, + lastSavedString: { + type: String, + default: '', + }, + document: { + type: Object, + default: null, + }, + syncError: { + type: Object, + default: null, + }, + sessions: { + type: Object, + default: () => { return {} }, + }, + }, + + computed: { + lastSavedStatus() { + if (this.hasConnectionIssue) { + return t('text', + this.$isMobile + ? 'Offline' + : 'Offline, changes will be saved when online' + ) + } + return this.dirtyStateIndicator ? t('text', 'Saving …') : t('text', 'Saved') + }, + lastSavedStatusClass() { + return this.syncError && this.lastSavedString !== '' ? 'error' : '' + }, + dirtyStateIndicator() { + return this.dirty || this.hasUnsavedChanges + }, + lastSavedStatusTooltip() { + let message = t('text', 'Last saved {lastSaved}', { lastSaved: this.lastSavedString }) + if (this.hasSyncCollission) { + message = t('text', 'The document has been changed outside of the editor. The changes cannot be applied.') + } + if (this.dirty || this.hasUnsavedChanges) { + message += ' - ' + t('text', 'Unsaved changes') + } + return { content: message, placement: 'bottom' } + }, + + hasUnsavedChanges() { + return this.document && this.document.lastSavedVersion < this.document.currentVersion + }, + hasSyncCollission() { + return this.syncError && this.syncError.type === ERROR_TYPE.SAVE_COLLISSION + }, + saveStatusClass() { + if (this.syncError && this.lastSavedString !== '') { + return 'save-error' + } + return this.dirtyStateIndicator ? 'saving-status' : 'saved-status' + }, + currentSession() { + return Object.values(this.sessions).find((session) => session.isCurrent) + }, + }, +} +</script> + +<style scoped lang="scss"> + + .text-editor__session-list { + display: flex; + + input, div { + vertical-align: middle; + margin-left: 3px; + } + } + + .save-status { + display: inline-flex; + padding: 0; + text-overflow: ellipsis; + color: var(--color-text-lighter); + position: relative; + top: 9px; + min-width: 85px; + max-height: 36px; + + &.error { + background-color: var(--color-error); + color: var(--color-main-background); + border-radius: 3px; + } + } +</style> + +<style lang="scss"> + .saved-status,.saving-status { + display: inline-flex; + padding: 0; + text-overflow: ellipsis; + color: var(--color-text-lighter); + position: relative; + background-color: white; + width: 38px !important; + height: 38px !important; + left: 25%; + z-index: 2; + top: 0px; + } + + .saved-status { + border: 2px solid #04AA6D; + border-radius: 50%; + } + + .saving-status { + border: 2px solid #f3f3f3; + border-top: 2px solid #3498db; + border-radius: 50%; + animation: spin 2s linear infinite; + } + + .last-saved { + padding: 6px; + } +</style> |