mirror of
https://github.com/nextcloud/server.git
synced 2026-02-18 18:28:50 -05:00
feat(files): grid view
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
694fd51cba
commit
16975ae457
8 changed files with 604 additions and 66 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
414
apps/files/src/components/FileEntryGrid.vue
Normal file
414
apps/files/src/components/FileEntryGrid.vue
Normal file
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue