diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-10-13 16:49:54 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-10-17 11:19:02 +0200 |
commit | 16975ae45720945776155f026835cfdaf8491383 (patch) | |
tree | 6eb6db9dee1d86a7da98c46b10d0dd9ea004dcc7 | |
parent | 694fd51cbaa18acbaa76a100010f00b904f96f7b (diff) | |
download | nextcloud-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.vue | 34 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryActions.vue | 8 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryName.vue | 5 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryPreview.vue | 8 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryGrid.vue | 414 | ||||
-rw-r--r-- | apps/files/src/components/FilesListTableFooter.vue | 13 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 108 | ||||
-rw-r--r-- | apps/files/src/components/VirtualList.vue | 80 |
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') }, }, |