Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax <max@nextcloud.com>2022-07-08 19:47:29 +0300
committerMax <max@nextcloud.com>2022-07-29 15:15:08 +0300
commit17146ed0fd8e2a7e66129280914081f632aebf20 (patch)
tree80c042e88c6eec9b311e2c00c64cc44254f2cb66 /src/components/Editor
parentcfa8c30de026084e4b13ba94d3c4edf3fff7ee28 (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.vue68
-rw-r--r--src/components/Editor/DocumentStatus.vue90
-rw-r--r--src/components/Editor/GuestNameDialog.vue117
-rw-r--r--src/components/Editor/SessionList.vue200
-rw-r--r--src/components/Editor/Status.vue198
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>