You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
nextcloud-server/apps/files/src/components/FilesListVirtual.vue

1023 lines
26 KiB

<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<VirtualList
ref="table"
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
data-key="source"
:data-sources="nodes"
:grid-mode="userConfig.grid_view"
:extra-props="{
isMimeAvailable,
isMtimeAvailable,
isSizeAvailable,
nodes,
}"
:scroll-to-index="scrollToIndex"
:caption="caption">
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
<template #before>
<!-- Headers -->
<FilesListHeader
v-for="header in headers"
:key="header.id"
:current-folder="currentFolder"
:current-view="currentView"
:header="header" />
</template>
<!-- Thead-->
<template #header>
<!-- Table header and sort buttons -->
<FilesListTableHeader
ref="thead"
:current-view="currentView"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes" />
</template>
<!-- Body replacement if no files are available -->
<template #empty>
<slot name="empty" />
</template>
<!-- Tfoot-->
<template #footer>
<FilesListTableFooter
:current-view="currentView"
:is-mime-available="isMimeAvailable"
:is-mtime-available="isMtimeAvailable"
:is-size-available="isSizeAvailable"
:nodes="nodes"
:summary="summary" />
</template>
</VirtualList>
</template>
<script lang="ts">
import type { INode } from '@nextcloud/files'
import type { ComponentPublicInstance, PropType } from 'vue'
import type { UserConfig } from '../types.ts'
import { showError } from '@nextcloud/dialogs'
import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, defineComponent } from 'vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FileListFilterChips from './FileListFilter/FileListFilterChips.vue'
import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
import VirtualList from './VirtualList.vue'
import { useEnabledFileActions } from '../composables/useFileActions.ts'
import { useFileListHeaders } from '../composables/useFileListHeaders.ts'
import { useFileListWidth } from '../composables/useFileListWidth.ts'
import { useRouteParameters } from '../composables/useRouteParameters.ts'
import { useActiveStore } from '../store/active.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { logger } from '../utils/logger.ts'
export default defineComponent({
name: 'FilesListVirtual',
components: {
FileListFilterChips,
FileListFilterToSearch,
FilesListHeader,
FilesListTableFooter,
FilesListTableHeader,
VirtualList,
},
props: {
currentView: {
type: View,
required: true,
},
currentFolder: {
type: Folder,
required: true,
},
nodes: {
type: Array as PropType<INode[]>,
required: true,
},
summary: {
type: String,
required: true,
},
},
setup(props) {
const sidebar = getSidebar()
const activeStore = useActiveStore()
const userConfigStore = useUserConfigStore()
const { isNarrow, isWide } = useFileListWidth()
const { fileId, openDetails, openFile } = useRouteParameters()
const isMimeAvailable = computed(() => {
if (!userConfigStore.userConfig.show_mime_column) {
return false
}
if (!isWide.value) {
return false // only show on wide screens
}
return props.nodes
.some((node: INode) => node.mime !== undefined || node.mime !== 'application/octet-stream')
})
const isMtimeAvailable = computed(() => {
// Hide mtime column on narrow screens
if (isNarrow.value) {
return false // hide on narrow screens
}
return props.nodes.some((node: INode) => node.mtime !== undefined)
})
const isSizeAvailable = computed(() => {
// Hide size column on narrow screens
if (isNarrow.value) {
return false // hide on narrow screens
}
return props.nodes.some((node: INode) => node.size !== undefined)
})
return {
fileId,
headers: useFileListHeaders(),
isSizeAvailable,
isMtimeAvailable,
isMimeAvailable,
openDetails,
openFile,
sidebar,
activeStore,
userConfigStore,
t,
}
},
data() {
return {
FileEntry,
FileEntryGrid,
scrollToIndex: 0,
}
},
computed: {
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
cantUpload() {
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0
},
isQuotaExceeded() {
return this.currentFolder?.attributes?.['quota-available-bytes'] === 0
},
caption() {
const defaultCaption = t('files', 'List of files and folders.')
const viewCaption = this.currentView.caption || defaultCaption
const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null
const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null
const sortableCaption = t('files', 'Column headers with buttons are sortable.')
const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.')
return [
viewCaption,
cantUploadCaption,
quotaExceededCaption,
sortableCaption,
virtualListNote,
].filter(Boolean).join('\n')
},
isEmpty() {
return this.nodes.length === 0
},
},
watch: {
// If nodes gets populated and we have a fileId,
// an openFile or openDetails, we fire the appropriate actions.
isEmpty() {
this.handleOpenQueries()
},
fileId() {
this.handleOpenQueries()
},
openFile() {
this.handleOpenQueries()
},
openDetails() {
this.handleOpenQueries()
},
},
created() {
useHotKey('Escape', this.unselectFile, {
stop: true,
prevent: true,
})
useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, {
stop: true,
prevent: true,
})
},
mounted() {
// Add events on parent to cover both the table and DragAndDrop notice
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.addEventListener('dragover', this.onDragOver)
},
beforeUnmount() {
const mainContent = window.document.querySelector('main.app-content') as HTMLElement
mainContent.removeEventListener('dragover', this.onDragOver)
},
methods: {
handleOpenQueries() {
// If the list is empty, or we don't have a fileId,
// there's nothing to be done.
if (this.isEmpty || this.fileId === null) {
return
}
logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', {
nodes: this.nodes,
fileId: this.fileId,
openFile: this.openFile,
openDetails: this.openDetails,
})
if (this.openFile) {
this.handleOpenFile(this.fileId)
}
if (this.openDetails) {
this.openSidebarForFile(this.fileId)
}
if (this.fileId) {
const node = this.nodes.find((node) => node.fileid === this.fileId)
if (node) {
this.activeStore.activeNode = node
this.scrollToFile(this.fileId, false)
}
}
},
openSidebarForFile(fileId) {
// Open the sidebar for the given URL fileid
// iif we just loaded the app.
const node = this.nodes.find((n) => n.fileid === fileId) as INode
if (node && this.sidebar.available) {
logger.debug('Opening sidebar on file ' + node.path, { node })
this.sidebar.open(node)
} else {
logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node })
}
},
scrollToFile(fileId: number | null, warn = true) {
if (fileId) {
// Do not uselessly scroll to the top of the list.
if (fileId === this.currentFolder.fileid) {
return
}
const index = this.nodes.findIndex((node) => node.fileid === fileId)
if (warn && index === -1 && fileId !== this.currentFolder.fileid) {
showError(t('files', 'File not found'))
}
this.scrollToIndex = Math.max(0, index)
logger.debug('Scrolling to file ' + fileId, { fileId, index })
}
},
/**
* Unselect the current file and clear open parameters from the URL
*/
unselectFile() {
const query = { ...this.$route.query }
delete query.openfile
delete query.opendetails
this.activeStore.activeNode = this.currentFolder
},
/**
* Handle opening a file (e.g. by ?openfile=true)
*
* @param fileId File to open
*/
async handleOpenFile(fileId: number) {
const node = this.nodes.find((n) => n.fileid === fileId) as INode
if (node === undefined) {
return
}
if (node.type === FileType.File) {
const actions = useEnabledFileActions({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})
const defaultAction = actions.value.find((action) => action.id !== 'download' && !!action.default)
// Some file types do not have a default action (e.g. they can only be downloaded)
// So if there is an enabled default action, so execute it
if (defaultAction) {
logger.debug('Opening file ' + node.path, { node })
return await defaultAction.exec({
nodes: [node],
view: this.currentView,
folder: this.currentFolder,
contents: this.nodes,
})
}
}
// The file is either a folder or has no default action other than downloading
// in this case we need to open the details instead and remove the route from the history
logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node })
window.OCP.Files.Router.goToRoute(
null,
window.OCP.Files.Router.params,
{ ...this.$route.query, openfile: undefined, opendetails: '' },
true, // silent update of the URL
)
},
onDragOver(event: DragEvent) {
// Detect if we're only dragging existing files or not
const isForeignFile = event.dataTransfer?.types.includes('Files')
if (isForeignFile) {
// Only handle uploading of existing Nextcloud files
// See DragAndDropNotice for handling of foreign files
return
}
event.preventDefault()
event.stopPropagation()
const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el
const tableTop = tableElement.getBoundingClientRect().top
const tableBottom = tableTop + tableElement.getBoundingClientRect().height
// If reaching top, scroll up. Using 100 because of the floating header
if (event.clientY < tableTop + 100) {
tableElement.scrollTop = tableElement.scrollTop - 25
return
}
// If reaching bottom, scroll down
if (event.clientY > tableBottom - 50) {
tableElement.scrollTop = tableElement.scrollTop + 25
}
},
onKeyDown(event: KeyboardEvent) {
if (this.isEmpty) {
return
}
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown'
&& (!this.userConfig.grid_view || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight'))) {
// not an arrow key we handle
return
}
if (!this.fileId || this.fileId === this.currentFolder.fileid) {
// no active node so use either first or last node
const index = event.key === 'ArrowUp' || event.key === 'ArrowLeft'
? this.nodes.length - 1
: 0
this.setActiveNode(this.nodes[index] as INode & { fileid: number })
}
const index = this.nodes.findIndex((node) => node.fileid === this.fileId) ?? 0
// Up and down arrow keys
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const columnCount = this.$refs.table?.columnCount ?? 1
const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
return
}
const nextNode = this.nodes[nextIndex]
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
return
}
}
// if grid mode, left and right arrow keys
if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1
if (nextIndex < 0 || nextIndex >= this.nodes.length) {
return
}
const nextNode = this.nodes[nextIndex]
if (nextNode && nextNode?.fileid) {
this.setActiveNode(nextNode)
return
}
}
},
async setActiveNode(node: INode & { fileid: number }) {
logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid })
this.scrollToFile(node.fileid)
// Remove openfile and opendetails from the URL
const query = { ...this.$route.query }
delete query.openfile
delete query.opendetails
await this.$router.replace({
...this.$route,
query,
})
// set the new file as active
this.activeStore.activeNode = node
},
},
})
</script>
<style scoped lang="scss">
.files-list {
--row-height: 44px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: var(--default-clickable-area);
--icon-preview-size: 24px;
--fixed-block-start-position: calc(var(--clickable-area-small) + var(--default-grid-baseline, 4px));
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
will-change: scroll-position;
&:has(&__filters:empty) {
--fixed-block-start-position: 0px;
}
& :deep() {
// Table head, body and footer
tbody {
will-change: padding;
contain: layout paint style;
display: flex;
flex-direction: column;
width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
/* Hover effect on tbody lines only */
tr {
contain: strict;
&:hover,
&:focus {
background-color: var(--color-background-dark);
}
}
}
// Before table and thead
.files-list__before {
display: flex;
flex-direction: column;
}
.files-list__table {
display: block;
// Visually hide the table when there are no files
&--hidden {
visibility: hidden;
z-index: -1;
opacity: 0;
}
}
.files-list__filters {
display: flex;
gap: var(--default-grid-baseline);
box-sizing: border-box;
// Pinned on top when scrolling above table header
position: sticky;
top: 0;
// ensure there is a background to hide the file list on scroll
background-color: var(--color-main-background);
z-index: 10;
// fixed the size
padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
height: var(--fixed-block-start-position);
width: 100%;
&:not(:empty) {
padding-block: calc(var(--default-grid-baseline, 4px) / 2);
}
}
.files-list__thead,
.files-list__tfoot {
display: flex;
flex-direction: column;
width: 100%;
background-color: var(--color-main-background);
}
// Table header
.files-list__thead {
// Pinned on top when scrolling
position: sticky;
z-index: 10;
top: var(--fixed-block-start-position);
}
// Empty content
.files-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
tr {
position: relative;
display: flex;
align-items: center;
width: 100%;
border-block-end: 1px solid var(--color-border);
box-sizing: border-box;
user-select: none;
height: var(--row-height);
}
td, th {
display: flex;
align-items: center;
flex: 0 0 auto;
justify-content: start;
width: var(--row-height);
height: var(--row-height);
margin: 0;
padding: 0;
color: var(--color-text-maxcontrast);
border: none;
// Columns should try to add any text
// node wrapped in a span. That should help
// with the ellipsis on overflow.
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.files-list__row--failed {
position: absolute;
display: block;
top: 0;
inset-inline: 0;
bottom: 0;
opacity: .1;
z-index: -1;
background: var(--color-error);
}
.files-list__row-checkbox {
justify-content: center;
.checkbox-radio-switch {
display: flex;
justify-content: center;
--icon-size: var(--checkbox-size);
label.checkbox-radio-switch__label {
width: var(--clickable-area);
height: var(--clickable-area);
margin: 0;
padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
}
.checkbox-radio-switch__icon {
margin: 0 !important;
}
}
}
// When files are selected, batch actions live in the select-all cell
// and other headers are visually hidden (kept in DOM for column association).
.files-list__row-head--selected {
.files-list__row-checkbox {
flex: 1 1 auto;
width: auto;
min-width: var(--row-height);
max-width: 100%;
justify-content: flex-start;
gap: 12px;
overflow: hidden;
}
}
.files-list__row {
&:hover, &:focus, &:active, &--active, &--dragover {
// WCAG AA compliant
background-color: var(--color-background-hover);
// text-maxcontrast have been designed to pass WCAG AA over
// a white background, we need to adjust then.
--color-text-maxcontrast: var(--color-main-text);
> * {
--color-border: var(--color-border-dark);
}
// Hover state of the row should also change the favorite markers background
.favorite-marker-icon svg path {
stroke: var(--color-background-hover);
}
}
&--dragover * {
// Prevent dropping on row children
pointer-events: none;
}
}
// Entry preview or mime icon
.files-list__row-icon {
position: relative;
display: flex;
overflow: visible;
align-items: center;
// No shrinking or growing allowed
flex: 0 0 var(--icon-preview-size);
justify-content: center;
width: var(--icon-preview-size);
height: 100%;
// Show same padding as the checkbox right padding for visual balance
margin-inline-end: var(--checkbox-padding);
color: var(--color-primary-element);
// Icon is also clickable
* {
cursor: pointer;
}
& > span {
justify-content: flex-start;
&:not(.files-list__row-icon-favorite):not(.files-list__row-icon-recently-created) svg {
width: var(--icon-preview-size);
height: var(--icon-preview-size);
}
// Slightly increase the size of the folder icon
&.folder-icon,
&.folder-open-icon {
margin: -3px;
svg {
width: calc(var(--icon-preview-size) + 6px);
height: calc(var(--icon-preview-size) + 6px);
}
}
}
&-preview-container {
position: relative; // Needed for the blurshash to be positioned correctly
overflow: hidden;
width: var(--icon-preview-size);
height: var(--icon-preview-size);
border-radius: var(--border-radius);
}
&-blurhash {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
object-fit: cover;
}
&-preview {
// Center and contain the preview
object-fit: contain;
object-position: center;
height: 100%;
width: 100%;
/* Preview not loaded animation effect */
&:not(.files-list__row-icon-preview--loaded) {
background: var(--color-loading-dark);
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
}
}
&-favorite,
&-recently-created {
position: absolute;
top: 0px;
inset-inline-end: -10px;
}
// File and folder overlay
&-overlay {
position: absolute;
max-height: calc(var(--icon-preview-size) * 0.6);
max-width: calc(var(--icon-preview-size) * 0.6);
color: var(--color-primary-element-text);
// better alignment with the folder icon
margin-block-start: 2px;
// Improve icon contrast with a background for files
&--file {
color: var(--color-main-text);
background: var(--color-main-background);
border-radius: 100%;
}
}
}
// Entry link
.files-list__row-name {
// Prevent link from overflowing
overflow: hidden;
// Take as much space as possible
flex: 1 1 auto;
button.files-list__row-name-link {
display: flex;
align-items: center;
text-align: start;
// Fill cell height and width
width: 100%;
height: 100%;
// Necessary for flex grow to work
min-width: 0;
margin: 0;
padding: 0;
// Already added to the inner text, see rule below
&:focus-visible {
outline: none !important;
}
// Keyboard indicator a11y
&:focus .files-list__row-name-text {
outline: var(--border-width-input-focused) solid var(--color-main-text) !important;
border-radius: var(--border-radius-element);
}
&:focus:not(:focus-visible) .files-list__row-name-text {
outline: none !important;
}
}
.files-list__row-name-text {
color: var(--color-main-text);
// Make some space for the outline
padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
padding-inline-start: -10px;
// Align two name and ext
display: inline-flex;
}
.files-list__row-name-ext {
color: var(--color-text-maxcontrast);
// always show the extension
overflow: visible;
}
}
// Rename form
.files-list__row-rename {
width: 100%;
max-width: 600px;
input {
width: 100%;
// Align with text, 0 - padding - border
margin-inline-start: -8px;
padding: 2px 6px;
border-width: 2px;
&:invalid {
// Show red border on invalid input
border-color: var(--color-border-error);
color: var(--color-text-error);
}
}
}
.files-list__row-actions {
// take as much space as necessary
width: auto;
// Add margin to all cells after the actions
& ~ td,
& ~ th {
margin: 0 var(--cell-margin);
}
button {
.button-vue__text {
// Remove bold from default button styling
font-weight: normal;
}
}
}
.files-list__row-action--inline {
margin-inline-end: 7px;
}
.files-list__row-mime,
.files-list__row-mtime,
.files-list__row-size {
color: var(--color-text-maxcontrast);
}
.files-list__row-size {
width: calc(var(--row-height) * 2);
// Right align content/text
justify-content: flex-end;
}
.files-list__row-mtime {
width: calc(var(--row-height) * 2.5);
}
.files-list__row-mime {
width: calc(var(--row-height) * 3.5);
}
.files-list__row-column-custom {
width: calc(var(--row-height) * 2.5);
}
}
}
@media screen and (max-width: 512px) {
.files-list :deep(.files-list__filters) {
// Reduce padding on mobile
padding-inline: var(--default-grid-baseline, 4px);
}
}
</style>
<style lang="scss">
// Grid mode
.files-list--grid tbody.files-list__tbody {
--item-padding: 16px;
--icon-preview-size: 166px;
--name-height: var(--default-clickable-area);
--mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline));
--row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2);
--row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2);
--checkbox-padding: 0px;
display: grid;
grid-template-columns: repeat(auto-fill, var(--row-width));
align-content: center;
align-items: center;
justify-content: space-around;
justify-items: center;
tr {
display: flex;
flex-direction: column;
width: var(--row-width);
height: var(--row-height);
border: none;
border-radius: var(--border-radius-large);
padding: var(--item-padding);
}
// Checkbox in the top left
.files-list__row-checkbox {
position: absolute;
z-index: 9;
top: calc(var(--item-padding) / 2);
inset-inline-start: calc(var(--item-padding) / 2);
overflow: hidden;
--checkbox-container-size: 44px;
width: var(--checkbox-container-size);
height: var(--checkbox-container-size);
// Add a background to the checkbox so we do not see the image through it.
.checkbox-radio-switch__content::after {
content: '';
width: 16px;
height: 16px;
position: absolute;
inset-inline-start: 50%;
margin-inline-start: -8px;
z-index: -1;
background: var(--color-main-background);
}
}
// Icon in the top right
.files-list__row-icon-favorite,
.files-list__row-icon-recently-created {
position: absolute;
top: 0;
inset-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
width: var(--clickable-area);
height: var(--clickable-area);
}
.files-list__row-name {
display: flex;
flex-direction: column;
width: var(--icon-preview-size);
height: calc(var(--icon-preview-size) + var(--name-height));
// Ensure that the name outline is visible.
overflow: visible;
span.files-list__row-icon {
width: var(--icon-preview-size);
height: var(--icon-preview-size);
}
.files-list__row-name-text {
margin: 0;
// Ensure that the outline is not too close to the text.
margin-inline-start: -4px;
padding: 0px 4px;
}
}
.files-list__row-mtime {
width: var(--icon-preview-size);
height: var(--mtime-height);
font-size: var(--font-size-small);
}
.files-list__row-actions {
position: absolute;
inset-inline-end: calc(var(--clickable-area) / 4);
inset-block-end: calc(var(--mtime-height) / 2);
width: var(--clickable-area);
height: var(--clickable-area);
}
}
@media screen and (max-width: 768px) {
// there is no mtime
.files-list--grid tbody.files-list__tbody {
--mtime-height: 0px;
// so we move the action to the name
.files-list__row-actions {
inset-block-end: var(--item-padding);
}
// and we need to keep space on the name for the actions
.files-list__row-name-text {
padding-inline-end: var(--clickable-area) !important;
}
}
}
</style>