aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-10-13 16:49:54 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-10-17 11:19:02 +0200
commit16975ae45720945776155f026835cfdaf8491383 (patch)
tree6eb6db9dee1d86a7da98c46b10d0dd9ea004dcc7
parent694fd51cbaa18acbaa76a100010f00b904f96f7b (diff)
downloadnextcloud-server-16975ae45720945776155f026835cfdaf8491383.tar.gz
nextcloud-server-16975ae45720945776155f026835cfdaf8491383.zip
feat(files): grid view
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
-rw-r--r--apps/files/src/components/FileEntry.vue34
-rw-r--r--apps/files/src/components/FileEntry/FileEntryActions.vue8
-rw-r--r--apps/files/src/components/FileEntry/FileEntryName.vue5
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue8
-rw-r--r--apps/files/src/components/FileEntryGrid.vue414
-rw-r--r--apps/files/src/components/FilesListTableFooter.vue13
-rw-r--r--apps/files/src/components/FilesListVirtual.vue108
-rw-r--r--apps/files/src/components/VirtualList.vue80
8 files changed, 604 insertions, 66 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 40a271aa972..adfaab8cc9a 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -71,7 +71,7 @@
:visible="visible" />
<!-- Size -->
- <td v-if="isSizeAvailable"
+ <td v-if="!compact && isSizeAvailable"
:style="sizeOpacity"
class="files-list__row-size"
data-cy-files-list-row-size
@@ -80,7 +80,7 @@
</td>
<!-- Mtime -->
- <td v-if="isMtimeAvailable"
+ <td v-if="!compact && isMtimeAvailable"
:style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@@ -170,6 +170,10 @@ export default Vue.extend({
type: Number,
default: 0,
},
+ compact: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
@@ -200,7 +204,7 @@ export default Vue.extend({
},
columns() {
// Hide columns if the list is too small
- if (this.filesListWidth < 512) {
+ if (this.filesListWidth < 512 || this.compact) {
return []
}
return this.currentView?.columns || []
@@ -513,27 +517,3 @@ export default Vue.extend({
},
})
</script>
-
-<style scoped lang='scss'>
-/* Hover effect on tbody lines only */
-tr {
- &:hover,
- &:focus {
- background-color: var(--color-background-dark);
- }
-}
-</style>
-
-<style>
-/* @keyframes preview-gradient-fade {
- 0% {
- opacity: 1;
- }
- 50% {
- opacity: 0.5;
- }
- 100% {
- opacity: 1;
- }
-} */
-</style>
diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue
index e8af5c0fe16..040b59066ec 100644
--- a/apps/files/src/components/FileEntry/FileEntryActions.vue
+++ b/apps/files/src/components/FileEntry/FileEntryActions.vue
@@ -105,6 +105,10 @@ export default Vue.extend({
type: Boolean,
default: false,
},
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
@@ -137,7 +141,7 @@ export default Vue.extend({
// Enabled action that are displayed inline
enabledInlineActions() {
- if (this.filesListWidth < 768) {
+ if (this.filesListWidth < 768 || this.gridMode) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
@@ -145,7 +149,7 @@ export default Vue.extend({
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
- if (!this.visible) {
+ if (!this.visible || this.gridMode) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue
index d70eccec8a0..e54eacdbe9e 100644
--- a/apps/files/src/components/FileEntry/FileEntryName.vue
+++ b/apps/files/src/components/FileEntry/FileEntryName.vue
@@ -23,7 +23,6 @@
<!-- Rename input -->
<form v-if="isRenaming"
v-on-click-outside="stopRenaming"
- :aria-hidden="!isRenaming"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
@@ -98,6 +97,10 @@ export default Vue.extend({
type: Object as PropType<Node>,
required: true,
},
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index 7766980b144..076319428e5 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -99,6 +99,10 @@ export default Vue.extend({
type: Boolean,
default: false,
},
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
@@ -146,8 +150,8 @@ export default Vue.extend({
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
- url.searchParams.set('x', '32')
- url.searchParams.set('y', '32')
+ url.searchParams.set('x', this.gridMode ? '128' : '32')
+ url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping
diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue
new file mode 100644
index 00000000000..d8c45cb2ce8
--- /dev/null
+++ b/apps/files/src/components/FileEntryGrid.vue
@@ -0,0 +1,414 @@
+<!--
+ - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license AGPL-3.0-or-later
+ -
+ - 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 :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
+ data-cy-files-list-row
+ :data-cy-files-list-row-fileid="fileid"
+ :data-cy-files-list-row-name="source.basename"
+ :draggable="canDrag"
+ class="files-list__row"
+ @contextmenu="onRightClick"
+ @dragover="onDragOver"
+ @dragleave="onDragLeave"
+ @dragstart="onDragStart"
+ @dragend="onDragEnd"
+ @drop="onDrop">
+ <!-- Failed indicator -->
+ <span v-if="source.attributes.failed" class="files-list__row--failed" />
+
+ <!-- Checkbox -->
+ <FileEntryCheckbox v-if="visible"
+ :display-name="displayName"
+ :fileid="fileid"
+ :is-loading="isLoading"
+ :nodes="nodes" />
+
+ <!-- Link to file -->
+ <td class="files-list__row-name" data-cy-files-list-row-name>
+ <!-- Icon or preview -->
+ <FileEntryPreview ref="preview"
+ :dragover="dragover"
+ :grid-mode="true"
+ :source="source"
+ @click.native="execDefaultAction" />
+
+ <FileEntryName ref="name"
+ :display-name="displayName"
+ :extension="extension"
+ :files-list-width="filesListWidth"
+ :grid-mode="true"
+ :nodes="nodes"
+ :source="source"
+ @click="execDefaultAction" />
+ </td>
+
+ <!-- Actions -->
+ <FileEntryActions ref="actions"
+ :class="`files-list__row-actions-${uniqueId}`"
+ :files-list-width="filesListWidth"
+ :grid-mode="true"
+ :loading.sync="loading"
+ :opened.sync="openedMenu"
+ :source="source"
+ :visible="visible" />
+ </tr>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { extname, join } from 'path'
+import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
+import { getUploader } from '@nextcloud/upload'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { vOnClickOutside } from '@vueuse/components'
+import Vue from 'vue'
+
+import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import { getDragAndDropPreview } from '../utils/dragUtils.ts'
+import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
+import { hashCode } from '../utils/hashUtils.ts'
+import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
+import { useActionsMenuStore } from '../store/actionsmenu.ts'
+import { useDragAndDropStore } from '../store/dragging.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useRenamingStore } from '../store/renaming.ts'
+import { useSelectionStore } from '../store/selection.ts'
+import FileEntryActions from './FileEntry/FileEntryActions.vue'
+import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
+import FileEntryName from './FileEntry/FileEntryName.vue'
+import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
+import logger from '../logger.js'
+
+Vue.directive('onClickOutside', vOnClickOutside)
+
+export default Vue.extend({
+ name: 'FileEntryGrid',
+
+ components: {
+ FileEntryActions,
+ FileEntryCheckbox,
+ FileEntryName,
+ FileEntryPreview,
+ },
+
+ inheritAttrs: false,
+ props: {
+ visible: {
+ type: Boolean,
+ default: false,
+ },
+ source: {
+ type: [Folder, NcFile, Node] as PropType<Node>,
+ required: true,
+ },
+ nodes: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ filesListWidth: {
+ type: Number,
+ default: 0,
+ },
+ },
+
+ setup() {
+ const actionsMenuStore = useActionsMenuStore()
+ const draggingStore = useDragAndDropStore()
+ const filesStore = useFilesStore()
+ const renamingStore = useRenamingStore()
+ const selectionStore = useSelectionStore()
+ return {
+ actionsMenuStore,
+ draggingStore,
+ filesStore,
+ renamingStore,
+ selectionStore,
+ }
+ },
+
+ data() {
+ return {
+ loading: '',
+ dragover: false,
+ }
+ },
+
+ computed: {
+ currentView(): View {
+ return this.$navigation.active as View
+ },
+
+ currentDir() {
+ // Remove any trailing slash but leave root slash
+ return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
+ },
+ currentFileId() {
+ return this.$route.params?.fileid || this.$route.query?.fileid || null
+ },
+ fileid() {
+ return this.source?.fileid?.toString?.()
+ },
+ uniqueId() {
+ return hashCode(this.source.source)
+ },
+ isLoading() {
+ return this.source.status === NodeStatus.LOADING
+ },
+
+ extension() {
+ if (this.source.attributes?.displayName) {
+ return extname(this.source.attributes.displayName)
+ }
+ return this.source.extension || ''
+ },
+ displayName() {
+ const ext = this.extension
+ const name = (this.source.attributes.displayName
+ || this.source.basename)
+
+ // Strip extension from name if defined
+ return !ext ? name : name.slice(0, 0 - ext.length)
+ },
+
+ draggingFiles() {
+ return this.draggingStore.dragging
+ },
+ selectedFiles() {
+ return this.selectionStore.selected
+ },
+ isSelected() {
+ return this.selectedFiles.includes(this.fileid)
+ },
+
+ isRenaming() {
+ return this.renamingStore.renamingNode === this.source
+ },
+
+ isActive() {
+ return this.fileid === this.currentFileId?.toString?.()
+ },
+
+ canDrag() {
+ const canDrag = (node: Node): boolean => {
+ return (node?.permissions & Permission.UPDATE) !== 0
+ }
+
+ // If we're dragging a selection, we need to check all files
+ if (this.selectedFiles.length > 0) {
+ const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ return nodes.every(canDrag)
+ }
+ return canDrag(this.source)
+ },
+
+ canDrop() {
+ if (this.source.type !== FileType.Folder) {
+ return false
+ }
+
+ // If the current folder is also being dragged, we can't drop it on itself
+ if (this.draggingFiles.includes(this.fileid)) {
+ return false
+ }
+
+ return (this.source.permissions & Permission.CREATE) !== 0
+ },
+
+ openedMenu: {
+ get() {
+ return this.actionsMenuStore.opened === this.uniqueId
+ },
+ set(opened) {
+ this.actionsMenuStore.opened = opened ? this.uniqueId : null
+ },
+ },
+ },
+
+ watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ */
+ source() {
+ this.resetState()
+ },
+ },
+
+ beforeDestroy() {
+ this.resetState()
+ },
+
+ methods: {
+ resetState() {
+ // Reset loading state
+ this.loading = ''
+
+ this.$refs.preview.reset()
+
+ // Close menu
+ this.openedMenu = false
+ },
+
+ // Open the actions menu on right click
+ onRightClick(event) {
+ // If already opened, fallback to default browser
+ if (this.openedMenu) {
+ return
+ }
+
+ // If the clicked row is in the selection, open global menu
+ const isMoreThanOneSelected = this.selectedFiles.length > 1
+ this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
+
+ // Prevent any browser defaults
+ event.preventDefault()
+ event.stopPropagation()
+ },
+
+ execDefaultAction(...args) {
+ this.$refs.actions.execDefaultAction(...args)
+ },
+
+ openDetailsIfAvailable(event) {
+ event.preventDefault()
+ event.stopPropagation()
+ if (sidebarAction?.enabled?.([this.source], this.currentView)) {
+ sidebarAction.exec(this.source, this.currentView, this.currentDir)
+ }
+ },
+
+ onDragOver(event: DragEvent) {
+ this.dragover = this.canDrop
+ if (!this.canDrop) {
+ event.dataTransfer.dropEffect = 'none'
+ return
+ }
+
+ // Handle copy/move drag and drop
+ if (event.ctrlKey) {
+ event.dataTransfer.dropEffect = 'copy'
+ } else {
+ event.dataTransfer.dropEffect = 'move'
+ }
+ },
+ onDragLeave(event: DragEvent) {
+ // Counter bubbling, make sure we're ending the drag
+ // only when we're leaving the current element
+ const currentTarget = event.currentTarget as HTMLElement
+ if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
+ return
+ }
+
+ this.dragover = false
+ },
+
+ async onDragStart(event: DragEvent) {
+ event.stopPropagation()
+ if (!this.canDrag) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
+ logger.debug('Drag started')
+
+ // Reset any renaming
+ this.renamingStore.$reset()
+
+ // Dragging set of files, if we're dragging a file
+ // that is already selected, we use the entire selection
+ if (this.selectedFiles.includes(this.fileid)) {
+ this.draggingStore.set(this.selectedFiles)
+ } else {
+ this.draggingStore.set([this.fileid])
+ }
+
+ const nodes = this.draggingStore.dragging
+ .map(fileid => this.filesStore.getNode(fileid)) as Node[]
+
+ const image = await getDragAndDropPreview(nodes)
+ event.dataTransfer?.setDragImage(image, -10, -10)
+ },
+ onDragEnd() {
+ this.draggingStore.reset()
+ this.dragover = false
+ logger.debug('Drag ended')
+ },
+
+ async onDrop(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ // If another button is pressed, cancel it
+ // This allows cancelling the drag with the right click
+ if (!this.canDrop || event.button !== 0) {
+ return
+ }
+
+ const isCopy = event.ctrlKey
+ this.dragover = false
+
+ logger.debug('Dropped', { event, selection: this.draggingFiles })
+
+ // Check whether we're uploading files
+ if (event.dataTransfer?.files?.length > 0) {
+ const uploader = getUploader()
+ event.dataTransfer.files.forEach((file: File) => {
+ uploader.upload(join(this.source.path, file.name), file)
+ })
+ logger.debug(`Uploading files to ${this.source.path}`)
+ return
+ }
+
+ const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
+ nodes.forEach(async (node: Node) => {
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ try {
+ // TODO: resolve potential conflicts prior and force overwrite
+ await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
+ } catch (error) {
+ logger.error('Error while moving file', { error })
+ if (isCopy) {
+ showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
+ } else {
+ showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
+ }
+ } finally {
+ Vue.set(node, 'status', undefined)
+ }
+ })
+
+ // Reset selection after we dropped the files
+ // if the dropped files are within the selection
+ if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
+ logger.debug('Dropped selection, resetting select store...')
+ this.selectionStore.reset()
+ }
+ },
+
+ t,
+ },
+})
+</script>
diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue
index 3e8f49deace..bca4604d57d 100644
--- a/apps/files/src/components/FilesListTableFooter.vue
+++ b/apps/files/src/components/FilesListTableFooter.vue
@@ -159,17 +159,16 @@ export default Vue.extend({
<style scoped lang="scss">
// Scoped row
tr {
- padding-bottom: 300px;
+ margin-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;
+ 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/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 914cfa7ec4d..e4c9694eda7 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -31,7 +31,7 @@
:data-component="FileEntry"
:data-key="'source'"
:data-sources="nodes"
- :item-height="56"
+ :grid-mode="false"
:extra-props="{
isMtimeAvailable,
isSizeAvailable,
@@ -90,7 +90,7 @@ import Vue from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import DragAndDropNotice from './DragAndDropNotice.vue'
-import FileEntry from './FileEntry.vue'
+import FileEntry from './FileEntryGrid.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
@@ -302,6 +302,14 @@ export default Vue.extend({
width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
+
+ /* Hover effect on tbody lines only */
+ tr {
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-dark);
+ }
+ }
}
// Before table and thead
@@ -340,6 +348,7 @@ export default Vue.extend({
user-select: none;
border-bottom: 1px solid var(--color-border);
user-select: none;
+ height: var(--row-height);
}
td, th {
@@ -485,8 +494,8 @@ export default Vue.extend({
// Folder overlay
&-overlay {
position: absolute;
- max-height: 18px;
- max-width: 18px;
+ max-height: calc(var(--icon-preview-size) * 0.5);
+ max-width: calc(var(--icon-preview-size) * 0.5);
color: var(--color-main-background);
// better alignment with the folder icon
margin-top: 2px;
@@ -533,6 +542,8 @@ export default Vue.extend({
.files-list__row-name-ext {
color: var(--color-text-maxcontrast);
+ // always show the extension
+ overflow: visible;
}
}
@@ -556,6 +567,7 @@ export default Vue.extend({
}
.files-list__row-actions {
+ // take as much space as necessary
width: auto;
// Add margin to all cells after the actions
@@ -596,3 +608,91 @@ export default Vue.extend({
}
}
</style>
+
+<style lang="scss">
+// Grid mode
+tbody.files-list__tbody.files-list__tbody--grid {
+ --half-clickable-area: calc(var(--clickable-area) / 2);
+ --row-width: 160px;
+ // We use half of the clickable area as visual balance margin
+ --row-height: calc(var(--row-width) - var(--half-clickable-area));
+ --icon-preview-size: calc(var(--row-width) - var(--clickable-area));
+ --checkbox-padding: 0px;
+
+ display: grid;
+ grid-template-columns: repeat(auto-fill, var(--row-width));
+ grid-gap: 15px;
+ row-gap: 15px;
+
+ align-content: center;
+ align-items: center;
+ justify-content: space-around;
+ justify-items: center;
+
+ tr {
+ width: var(--row-width);
+ height: calc(var(--row-height) + var(--clickable-area));
+ border: none;
+ border-radius: var(--border-radius);
+ }
+
+ // Checkbox in the top left
+ .files-list__row-checkbox {
+ position: absolute;
+ z-index: 9;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ border-radius: var(--half-clickable-area);
+ }
+
+ // Star icon in the top right
+ .files-list__row-icon-favorite {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name {
+ display: grid;
+ justify-content: stretch;
+ width: 100%;
+ height: 100%;
+ grid-auto-rows: var(--row-height) var(--clickable-area);
+
+ span.files-list__row-icon {
+ width: 100%;
+ height: 100%;
+ // Visual balance, we use half of the clickable area
+ // as a margin around the preview
+ padding-top: var(--half-clickable-area);
+ }
+
+ a.files-list__row-name-link {
+ // Minus action menu
+ width: calc(100% - var(--clickable-area));
+ height: var(--clickable-area);
+ }
+
+ .files-list__row-name-text {
+ margin: 0;
+ padding-right: 0;
+ }
+ }
+
+ .files-list__row-actions {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: var(--clickable-area);
+ height: var(--clickable-area);
+ }
+}
+</style>
diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue
index ef824d7ba91..6a415799034 100644
--- a/apps/files/src/components/VirtualList.vue
+++ b/apps/files/src/components/VirtualList.vue
@@ -11,7 +11,10 @@
</thead>
<!-- Body -->
- <tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody>
+ <tbody :style="tbodyStyle"
+ class="files-list__tbody"
+ :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
+ data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="(item, i) in renderedItems"
:key="i"
@@ -23,7 +26,6 @@
<!-- Footer -->
<tfoot v-show="isReady"
- ref="tfoot"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@@ -32,16 +34,18 @@
</template>
<script lang="ts">
-import { File, Folder, debounce } from 'debounce'
-import Vue from 'vue'
-import logger from '../logger.js'
+import type { File, Folder } from '@nextcloud/files'
+import { debounce } from 'debounce'
+import Vue, { PropType } from 'vue'
-// Items to render before and after the visible area
-const bufferItems = 3
+import filesListWidthMixin from '../mixins/filesListWidth.ts'
+import logger from '../logger.js'
export default Vue.extend({
name: 'VirtualList',
+ mixins: [filesListWidthMixin],
+
props: {
dataComponent: {
type: [Object, Function],
@@ -52,26 +56,25 @@ export default Vue.extend({
required: true,
},
dataSources: {
- type: Array as () => (File | Folder)[],
- required: true,
- },
- itemHeight: {
- type: Number,
+ type: Array as PropType<(File | Folder)[]>,
required: true,
},
extraProps: {
- type: Object,
+ type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
scrollToIndex: {
type: Number,
default: 0,
},
+ gridMode: {
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
- bufferItems,
index: this.scrollToIndex,
beforeHeight: 0,
headerHeight: 0,
@@ -86,11 +89,44 @@ export default Vue.extend({
return this.tableHeight > 0
},
+ // Items to render before and after the visible area
+ bufferItems() {
+ if (this.gridMode) {
+ return this.columnCount
+ }
+ return 3
+ },
+
+ itemHeight() {
+ // 160px + 44px (name) + 15px (grid gap)
+ return this.gridMode ? (160 + 44 + 15) : 56
+ },
+ // Grid mode only
+ itemWidth() {
+ // 160px + 15px grid gap
+ return 160 + 15
+ },
+
+ rowCount() {
+ return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2
+ },
+ columnCount() {
+ if (!this.gridMode) {
+ return 1
+ }
+ return Math.floor(this.filesListWidth / this.itemWidth)
+ },
+
startIndex() {
- return Math.max(0, this.index - bufferItems)
+ return Math.max(0, this.index - this.bufferItems)
},
shownItems() {
- return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
+ // If in grid mode, we need to multiply the number of rows by the number of columns
+ if (this.gridMode) {
+ return this.rowCount * this.columnCount
+ }
+
+ return this.rowCount
},
renderedItems(): (File | Folder)[] {
if (!this.isReady) {
@@ -100,11 +136,11 @@ export default Vue.extend({
},
tbodyStyle() {
- const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
+ const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
- const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
+ const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
return {
- paddingTop: `${this.startIndex * this.itemHeight}px`,
+ paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
}
},
@@ -119,7 +155,6 @@ export default Vue.extend({
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(() => {
@@ -132,13 +167,12 @@ export default Vue.extend({
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
+ this.$el.scrollTop = Math.floor((this.index * this.itemHeight) / this.rowCount) + this.beforeHeight
}
},
@@ -151,7 +185,7 @@ export default Vue.extend({
methods: {
onScroll() {
// Max 0 to prevent negative index
- this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
+ this.index = Math.max(0, Math.floor(Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight) * this.columnCount))
this.$emit('scroll')
},
},