diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-08-11 09:29:20 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-08-17 18:56:37 +0200 |
commit | 0f68d08b140a69c2385b42bf7bc194a1e0129de5 (patch) | |
tree | a955abdb4075575df02d80fd7927ba82b638e7d9 /apps | |
parent | 3344f0f121865e03d4bc076fe79e7d88f32836da (diff) | |
download | nextcloud-server-0f68d08b140a69c2385b42bf7bc194a1e0129de5.tar.gz nextcloud-server-0f68d08b140a69c2385b42bf7bc194a1e0129de5.zip |
feat: virtual scrolling update
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 23 | ||||
-rw-r--r-- | apps/files/src/components/FilesListFooter.vue | 175 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderActions.vue | 226 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderButton.vue | 122 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableFooter.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 1 | ||||
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 161 |
7 files changed, 690 insertions, 20 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index c540cc4e824..c271a6965d7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -157,24 +157,24 @@ <script lang='ts'> import { debounce } from 'debounce' import { emit } from '@nextcloud/event-bus' +import { extname } from 'path' import { formatFileSize, Permission } from '@nextcloud/files' import { Fragment } from 'vue-frag' -import { extname } from 'path' +import { generateUrl } from '@nextcloud/router' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' import { vOnClickOutside } from '@vueuse/components' import axios from '@nextcloud/axios' import CancelablePromise from 'cancelable-promise' import FileIcon from 'vue-material-design-icons/File.vue' import FolderIcon from 'vue-material-design-icons/Folder.vue' +import moment from '@nextcloud/moment' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import Vue from 'vue' -import type moment from 'moment' import { ACTION_DETAILS } from '../actions/sidebarAction.ts' import { getFileActions, DefaultType } from '../services/FileAction.ts' @@ -183,9 +183,9 @@ import { isCachedPreview } from '../services/PreviewService.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' import { useKeyboardStore } from '../store/keyboard.ts' +import { useRenamingStore } from '../store/renaming.ts' import { useSelectionStore } from '../store/selection.ts' import { useUserConfigStore } from '../store/userconfig.ts' -import { useRenamingStore } from '../store/renaming.ts' import CustomElementRender from './CustomElementRender.vue' import CustomSvgIconRender from './CustomSvgIconRender.vue' import FavoriteIcon from './FavoriteIcon.vue' @@ -489,21 +489,6 @@ export default Vue.extend({ }, watch: { - active(active, before) { - if (active === false && before === true) { - this.resetState() - - // When the row is not active anymore - // remove the display from the row to prevent - // keyboard interaction with it. - this.$el.parentNode.style.display = 'none' - return - } - - // Restore default tabindex - this.$el.parentNode.style.display = '' - }, - /** * When the source changes, reset the preview * and fetch the new one. diff --git a/apps/files/src/components/FilesListFooter.vue b/apps/files/src/components/FilesListFooter.vue new file mode 100644 index 00000000000..b4a2d7eda30 --- /dev/null +++ b/apps/files/src/components/FilesListFooter.vue @@ -0,0 +1,175 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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> + <tr> + <th class="files-list__row-checkbox"> + <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span> + </th> + + <!-- Link to file --> + <td class="files-list__row-name"> + <!-- Icon or preview --> + <span class="files-list__row-icon" /> + + <!-- Summary --> + <span>{{ summary }}</span> + </td> + + <!-- Actions --> + <td class="files-list__row-actions" /> + + <!-- Size --> + <td v-if="isSizeAvailable" + class="files-list__column files-list__row-size"> + <span>{{ totalSize }}</span> + </td> + + <!-- Mtime --> + <td v-if="isMtimeAvailable" + class="files-list__column files-list__row-mtime" /> + + <!-- Custom views columns --> + <th v-for="column in columns" + :key="column.id" + :class="classForColumn(column)"> + <span>{{ column.summary?.(nodes, currentView) }}</span> + </th> + </tr> +</template> + +<script lang="ts"> +import { formatFileSize } from '@nextcloud/files' +import { translate } from '@nextcloud/l10n' +import Vue from 'vue' + +import { useFilesStore } from '../store/files.ts' +import { usePathsStore } from '../store/paths.ts' + +export default Vue.extend({ + name: 'FilesListFooter', + + components: { + }, + + props: { + isMtimeAvailable: { + type: Boolean, + default: false, + }, + isSizeAvailable: { + type: Boolean, + default: false, + }, + nodes: { + type: Array, + required: true, + }, + summary: { + type: String, + default: '', + }, + filesListWidth: { + type: Number, + default: 0, + }, + }, + + setup() { + const pathsStore = usePathsStore() + const filesStore = useFilesStore() + return { + filesStore, + pathsStore, + } + }, + + computed: { + currentView() { + return this.$navigation.active + }, + + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + + currentFolder() { + if (!this.currentView?.id) { + return + } + + if (this.dir === '/') { + return this.filesStore.getRoot(this.currentView.id) + } + const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) + return this.filesStore.getNode(fileId) + }, + + columns() { + // Hide columns if the list is too small + if (this.filesListWidth < 512) { + return [] + } + return this.currentView?.columns || [] + }, + + totalSize() { + // If we have the size already, let's use it + if (this.currentFolder?.size) { + return formatFileSize(this.currentFolder.size, true) + } + + // Otherwise let's compute it + return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true) + }, + }, + + methods: { + classForColumn(column) { + return { + 'files-list__row-column-custom': true, + [`files-list__row-${this.currentView.id}-${column.id}`]: true, + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +// Scoped row +tr { + padding-bottom: 300px; + border-top: 1px solid var(--color-border); + // Prevent hover effect on the whole row + background-color: transparent !important; + border-bottom: none !important; +} + +td { + user-select: none; + // Make sure the cell colors don't apply to column headers + color: var(--color-text-maxcontrast) !important; +} + +</style> diff --git a/apps/files/src/components/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue new file mode 100644 index 00000000000..e419c8e5abd --- /dev/null +++ b/apps/files/src/components/FilesListHeaderActions.vue @@ -0,0 +1,226 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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> + <th class="files-list__column files-list__row-actions-batch" colspan="2"> + <NcActions ref="actionsMenu" + :disabled="!!loading || areSomeNodesLoading" + :force-name="true" + :inline="inlineActions" + :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null" + :open.sync="openedMenu"> + <NcActionButton v-for="action in enabledActions" + :key="action.id" + :class="'files-list__row-actions-batch-' + action.id" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + </NcActions> + </th> +</template> + +<script lang="ts"> +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate } from '@nextcloud/l10n' +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import Vue from 'vue' + +import { getFileActions } from '../services/FileAction.ts' +import { useActionsMenuStore } from '../store/actionsmenu.ts' +import { useFilesStore } from '../store/files.ts' +import { useSelectionStore } from '../store/selection.ts' +import filesListWidthMixin from '../mixins/filesListWidth.ts' +import CustomSvgIconRender from './CustomSvgIconRender.vue' +import logger from '../logger.js' + +// The registered actions list +const actions = getFileActions() + +export default Vue.extend({ + name: 'FilesListHeaderActions', + + components: { + CustomSvgIconRender, + NcActions, + NcActionButton, + NcLoadingIcon, + }, + + mixins: [ + filesListWidthMixin, + ], + + props: { + currentView: { + type: Object, + required: true, + }, + selectedNodes: { + type: Array, + default: () => ([]), + }, + }, + + setup() { + const actionsMenuStore = useActionsMenuStore() + const filesStore = useFilesStore() + const selectionStore = useSelectionStore() + return { + actionsMenuStore, + filesStore, + selectionStore, + } + }, + + data() { + return { + loading: null, + } + }, + + computed: { + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, + enabledActions() { + return actions + .filter(action => action.execBatch) + .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }, + + nodes() { + return this.selectedNodes + .map(fileid => this.getNode(fileid)) + .filter(node => node) + }, + + areSomeNodesLoading() { + return this.nodes.some(node => node._loading) + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === 'global' + }, + set(opened) { + this.actionsMenuStore.opened = opened ? 'global' : null + }, + }, + + inlineActions() { + if (this.filesListWidth < 512) { + return 0 + } + if (this.filesListWidth < 768) { + return 1 + } + if (this.filesListWidth < 1024) { + return 2 + } + return 3 + }, + }, + + methods: { + /** + * Get a cached note from the store + * + * @param {number} fileId the file id to get + * @return {Folder|File} + */ + getNode(fileId) { + return this.filesStore.getNode(fileId) + }, + + async onActionClick(action) { + const displayName = action.displayName(this.nodes, this.currentView) + const selectionIds = this.selectedNodes + try { + // Set loading markers + this.loading = action.id + this.nodes.forEach(node => { + Vue.set(node, '_loading', true) + }) + + // Dispatch action execution + const results = await action.execBatch(this.nodes, this.currentView, this.dir) + + // Check if all actions returned null + if (!results.some(result => result !== null)) { + // If the actions returned null, we stay silent + this.selectionStore.reset() + return + } + + // Handle potential failures + if (results.some(result => result === false)) { + // Remove the failed ids from the selection + const failedIds = selectionIds + .filter((fileid, index) => results[index] === false) + this.selectionStore.set(failedIds) + + showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) + return + } + + // Show success message and clear selection + showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName })) + this.selectionStore.reset() + } catch (e) { + logger.error('Error while executing action', { action, e }) + showError(this.t('files', '"{displayName}" action failed', { displayName })) + } finally { + // Remove loading markers + this.loading = null + this.nodes.forEach(node => { + Vue.set(node, '_loading', false) + }) + } + }, + + t: translate, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list__row-actions-batch { + flex: 1 1 100% !important; + + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged + ::v-deep .button-vue__wrapper { + width: 100%; + span.button-vue__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} +</style> diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue new file mode 100644 index 00000000000..9aac83a185d --- /dev/null +++ b/apps/files/src/components/FilesListHeaderButton.vue @@ -0,0 +1,122 @@ +<!-- + - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.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> + <NcButton :aria-label="sortAriaLabel(name)" + :class="{'files-list__column-sort-button--active': sortingMode === mode}" + class="files-list__column-sort-button" + type="tertiary" + @click.stop.prevent="toggleSortBy(mode)"> + <!-- Sort icon before text as size is align right --> + <MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" /> + <MenuDown v-else slot="icon" /> + {{ name }} + </NcButton> +</template> + +<script lang="ts"> +import { translate } from '@nextcloud/l10n' +import MenuDown from 'vue-material-design-icons/MenuDown.vue' +import MenuUp from 'vue-material-design-icons/MenuUp.vue' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import Vue from 'vue' + +import filesSortingMixin from '../mixins/filesSorting.ts' + +export default Vue.extend({ + name: 'FilesListHeaderButton', + + components: { + MenuDown, + MenuUp, + NcButton, + }, + + mixins: [ + filesSortingMixin, + ], + + props: { + name: { + type: String, + required: true, + }, + mode: { + type: String, + required: true, + }, + }, + + methods: { + sortAriaLabel(column) { + const direction = this.isAscSorting + ? this.t('files', 'ascending') + : this.t('files', 'descending') + return this.t('files', 'Sort list by {column} ({direction})', { + column, + direction, + }) + }, + + t: translate, + }, +}) +</script> + +<style lang="scss"> +.files-list__column-sort-button { + // Compensate for cells margin + margin: 0 calc(var(--cell-margin) * -1); + // Reverse padding + padding: 0 4px 0 16px !important; + + // Icon after text + .button-vue__wrapper { + flex-direction: row-reverse; + // Take max inner width for text overflow ellipsis + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged + width: 100%; + } + + .button-vue__icon { + transition-timing-function: linear; + transition-duration: .1s; + transition-property: opacity; + opacity: 0; + } + + // Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged + .button-vue__text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &--active, + &:hover, + &:focus, + &:active { + .button-vue__icon { + opacity: 1 !important; + } + } +} +</style> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 4bda140770d..3e8f49deace 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -20,7 +20,7 @@ - --> <template> - <tr class="files-list__row-footer"> + <tr> <th class="files-list__row-checkbox"> <span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span> </th> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 69cab260963..05de0a38750 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -233,6 +233,7 @@ export default Vue.extend({ width: 100%; user-select: none; border-bottom: 1px solid var(--color-border); + user-select: none; } td, th { diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue new file mode 100644 index 00000000000..7780665ab6b --- /dev/null +++ b/apps/files/src/components/VirtualList.vue @@ -0,0 +1,161 @@ +<template> + <table class="files-list"> + <!-- Header --> + <div ref="before" class="files-list__before"> + <slot name="before" /> + </div> + + <!-- Header --> + <thead ref="thead" class="files-list__thead"> + <slot name="header" /> + </thead> + + <!-- Body --> + <tbody :style="tbodyStyle" class="files-list__tbody"> + <tr v-for="(item, i) in renderedItems" + :key="i" + :class="{'list__row--active': (i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)}" + class="list__row"> + <component :is="dataComponent" + :active="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)" + :source="item" + :index="i" + :item-height="itemHeight" + v-bind="extraProps" /> + </tr> + </tbody> + + <!-- Footer --> + <tfoot ref="tfoot" class="files-list__tfoot"> + <slot name="footer" /> + </tfoot> + </table> +</template> + +<script lang="ts"> +import { File, Folder } from '@nextcloud/files' +import { debounce } from 'debounce' +import Vue from 'vue' +import logger from '../logger.js' + +// Items to render before and after the visible area +const bufferItems = 3 + +export default Vue.extend({ + name: 'VirtualList', + + props: { + dataComponent: { + type: [Object, Function], + required: true, + }, + dataKey: { + type: String, + required: true, + }, + dataSources: { + type: Array as () => (File | Folder)[], + required: true, + }, + itemHeight: { + type: Number, + required: true, + }, + extraProps: { + type: Object, + default: () => ({}), + }, + scrollToIndex: { + type: Number, + default: 0, + }, + }, + + data() { + return { + bufferItems, + index: this.scrollToIndex, + beforeHeight: 0, + footerHeight: 0, + headerHeight: 0, + tableHeight: 0, + resizeObserver: null as ResizeObserver | null, + } + }, + + computed: { + startIndex() { + return Math.max(0, this.index - bufferItems) + }, + shownItems() { + return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2 + }, + renderedItems(): (File | Folder)[] { + return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) + }, + + tbodyStyle() { + const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length + const lastIndex = this.dataSources.length - this.startIndex - this.shownItems + const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex) + return { + paddingTop: `${this.startIndex * this.itemHeight}px`, + paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + } + }, + }, + watch: { + scrollToIndex() { + this.index = this.scrollToIndex + this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight + }, + index() { + logger.debug('VirtualList index updated to ' + this.index) + }, + }, + + mounted() { + const before = this.$refs?.before as HTMLElement + const root = this.$el as HTMLElement + const tfoot = this.$refs?.tfoot as HTMLElement + const thead = this.$refs?.thead as HTMLElement + + this.resizeObserver = new ResizeObserver(debounce(() => { + this.beforeHeight = before?.clientHeight ?? 0 + this.footerHeight = tfoot?.clientHeight ?? 0 + this.headerHeight = thead?.clientHeight ?? 0 + this.tableHeight = root?.clientHeight ?? 0 + logger.debug('VirtualList resizeObserver updated') + this.onScroll() + }, 100, false)) + + this.resizeObserver.observe(before) + this.resizeObserver.observe(root) + this.resizeObserver.observe(tfoot) + this.resizeObserver.observe(thead) + + this.$el.addEventListener('scroll', this.onScroll) + + if (this.scrollToIndex) { + this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight + } + }, + + beforeDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } + }, + + methods: { + onScroll() { + // Max 0 to prevent negative index + this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight)) + }, + }, +}) +</script> + +<style scoped> + +</style> |