Forráskód Böngészése

feat: allow external drop and add dropzone

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
John Molakvoæ 8 hónapja
szülő
commit
35aed73ede

+ 5 - 3
apps/files/lib/Controller/ViewController.php

@@ -237,9 +237,11 @@ class ViewController extends Controller {
 		if ($fileid && $dir !== '') {
 			$baseFolder = $this->rootFolder->getUserFolder($userId);
 			$nodes = $baseFolder->getById((int) $fileid);
-			$relativePath = dirname($baseFolder->getRelativePath($nodes[0]->getPath()));
-			// If the requested path is different from the file path
-			if (count($nodes) === 1 && $relativePath !== $dir) {
+			$nodePath = $baseFolder->getRelativePath($nodes[0]->getPath());
+			$relativePath = $nodePath ? dirname($nodePath) : '';
+			// If the requested path does not contain the file id
+			// or if the requested path is not the file id itself
+			if (count($nodes) === 1 && $relativePath !== $dir && $nodePath !== $dir) {
 				return $this->redirectToFile((int) $fileid);
 			}
 		}

+ 155 - 0
apps/files/src/components/DragAndDropNotice.vue

@@ -0,0 +1,155 @@
+<!--
+	- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
+	-
+	- @author John Molakvoæ <skjnldsv@protonmail.com>
+	-
+	- @license GNU AGPL version 3 or any later version
+	-
+	- This program is free software: you can redistribute it and/or modify
+	- it under the terms of the GNU Affero General Public License as
+	- published by the Free Software Foundation, either version 3 of the
+	- License, or (at your option) any later version.
+	-
+	- This program is distributed in the hope that it will be useful,
+	- but WITHOUT ANY WARRANTY; without even the implied warranty of
+	- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	- GNU Affero General Public License for more details.
+	-
+	- You should have received a copy of the GNU Affero General Public License
+	- along with this program. If not, see <http://www.gnu.org/licenses/>.
+	-
+	-->
+<template>
+	<div class="files-list__drag-drop-notice"
+		:class="{ 'files-list__drag-drop-notice--dragover': dragover }"
+		@drop="onDrop">
+		<div class="files-list__drag-drop-notice-wrapper">
+			<TrayArrowDownIcon :size="48" />
+			<h3 class="files-list-drag-drop-notice__title">
+				{{ t('files', 'Drag and drop files here to upload') }}
+			</h3>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import type { Upload } from '@nextcloud/upload'
+import { join } from 'path'
+import { showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { getUploader } from '@nextcloud/upload'
+import Vue from 'vue'
+
+import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue'
+
+import logger from '../logger.js'
+
+export default Vue.extend({
+	name: 'DragAndDropNotice',
+
+	components: {
+		TrayArrowDownIcon,
+	},
+
+	props: {
+		currentFolder: {
+			type: Object,
+			required: true,
+		},
+		dragover: {
+			type: Boolean,
+			default: false,
+		},
+	},
+
+	methods: {
+		onDrop(event: DragEvent) {
+			this.$emit('update:dragover', false)
+
+			if (this.$el.querySelector('tbody')?.contains(event.target as Node)) {
+				return
+			}
+
+			event.preventDefault()
+			event.stopPropagation()
+
+			if (event.dataTransfer && event.dataTransfer.files?.length > 0) {
+				const uploader = getUploader()
+				uploader.destination = this.currentFolder
+
+				// Start upload
+				logger.debug(`Uploading files to ${this.currentFolder.path}`)
+				const promises = [...event.dataTransfer.files].map((file: File) => {
+					return uploader.upload(file.name, file) as Promise<Upload>
+				})
+
+				// Process finished uploads
+				Promise.all(promises).then((uploads) => {
+					logger.debug('Upload terminated', { uploads })
+					showSuccess(t('files', 'Upload successful'))
+
+					// Scroll to last upload if terminated
+					const lastUpload = uploads[uploads.length - 1]
+					if (lastUpload?.response?.headers?.['oc-fileid']) {
+						this.$router.push(Object.assign({}, this.$route, {
+							params: {
+								// Remove instanceid from header response
+								fileid: parseInt(lastUpload.response?.headers?.['oc-fileid']),
+							},
+						}))
+					}
+				})
+			}
+		},
+		t,
+	},
+})
+</script>
+
+<style lang="scss" scoped>
+.files-list__drag-drop-notice {
+	position: absolute;
+	z-index: 9999;
+	top: 0;
+	right: 0;
+	left: 0;
+	display: none;
+	align-items: center;
+	justify-content: center;
+	width: 100%;
+	// Breadcrumbs height + row thead height
+	min-height: calc(58px + 55px);
+	margin: 0;
+	user-select: none;
+	color: var(--color-text-maxcontrast);
+	background-color: var(--color-main-background);
+
+	&--dragover {
+		display: flex;
+		border-color: black;
+	}
+
+	h3 {
+		margin-left: 16px;
+		color: inherit;
+	}
+
+	&-wrapper {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		height: 15vh;
+		max-height: 70%;
+		padding: 0 5vw;
+		border: 2px var(--color-border-dark) dashed;
+		border-radius: var(--border-radius-large);
+	}
+
+	&__close {
+		position: absolute !important;
+		top: 10px;
+		right: 10px;
+	}
+}
+
+</style>

+ 47 - 31
apps/files/src/components/FileEntry.vue

@@ -189,12 +189,13 @@
 <script lang="ts">
 import type { PropType } from 'vue'
 
-import { emit } from '@nextcloud/event-bus'
-import { extname } from 'path'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { extname, join } from 'path'
 import { generateUrl } from '@nextcloud/router'
-import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File, FileAction, NodeStatus, Node } from '@nextcloud/files'
+import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File as NcFile, FileAction, NodeStatus, Node } from '@nextcloud/files'
+import { getUploader } from '@nextcloud/upload'
 import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate } from '@nextcloud/l10n'
+import { translate as t } from '@nextcloud/l10n'
 import { Type as ShareType } from '@nextcloud/sharing'
 import { vOnClickOutside } from '@vueuse/components'
 import axios from '@nextcloud/axios'
@@ -278,7 +279,7 @@ export default Vue.extend({
 			default: false,
 		},
 		source: {
-			type: [Folder, File, Node] as PropType<Node>,
+			type: [Folder, NcFile, Node] as PropType<Node>,
 			required: true,
 		},
 		index: {
@@ -369,7 +370,7 @@ export default Vue.extend({
 		size() {
 			const size = parseInt(this.source.size, 10) || 0
 			if (typeof size !== 'number' || size < 0) {
-				return this.t('files', 'Pending')
+				return t('files', 'Pending')
 			}
 			return formatFileSize(size, true)
 		},
@@ -391,7 +392,7 @@ export default Vue.extend({
 			if (this.source.mtime) {
 				return moment(this.source.mtime).fromNow()
 			}
-			return this.t('files_trashbin', 'A long time ago')
+			return t('files_trashbin', 'A long time ago')
 		},
 		mtimeOpacity() {
 			const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days
@@ -457,7 +458,7 @@ export default Vue.extend({
 		linkTo() {
 			if (this.source.attributes.failed) {
 				return {
-					title: this.t('files', 'This node is unavailable'),
+					title: t('files', 'This node is unavailable'),
 					is: 'span',
 				}
 			}
@@ -475,7 +476,7 @@ export default Vue.extend({
 				return {
 					download: this.source.basename,
 					href: this.source.source,
-					title: this.t('files', 'Download file {name}', { name: this.displayName }),
+					title: t('files', 'Download file {name}', { name: this.displayName }),
 				}
 			}
 
@@ -508,7 +509,7 @@ export default Vue.extend({
 
 			try {
 				const previewUrl = this.source.attributes.previewUrl
-					|| generateUrl('/core/preview?fileid={fileid}', {
+					|| generateUrl('/core/preview?fileId={fileid}', {
 						fileid: this.fileid,
 					})
 				const url = new URL(window.location.origin + previewUrl)
@@ -699,13 +700,13 @@ export default Vue.extend({
 				}
 
 				if (success) {
-					showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
+					showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
 					return
 				}
-				showError(this.t('files', '"{displayName}" action failed', { displayName }))
+				showError(t('files', '"{displayName}" action failed', { displayName }))
 			} catch (e) {
 				logger.error('Error while executing action', { action, e })
-				showError(this.t('files', '"{displayName}" action failed', { displayName }))
+				showError(t('files', '"{displayName}" action failed', { displayName }))
 			} finally {
 				// Reset the loading marker
 				this.loading = ''
@@ -803,15 +804,15 @@ export default Vue.extend({
 		isFileNameValid(name) {
 			const trimmedName = name.trim()
 			if (trimmedName === '.' || trimmedName === '..') {
-				throw new Error(this.t('files', '"{name}" is an invalid file name.', { name }))
+				throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
 			} else if (trimmedName.length === 0) {
-				throw new Error(this.t('files', 'File name cannot be empty.'))
+				throw new Error(t('files', 'File name cannot be empty.'))
 			} else if (trimmedName.indexOf('/') !== -1) {
-				throw new Error(this.t('files', '"/" is not allowed inside a file name.'))
+				throw new Error(t('files', '"/" is not allowed inside a file name.'))
 			} else if (trimmedName.match(OC.config.blacklist_files_regex)) {
-				throw new Error(this.t('files', '"{name}" is not an allowed filetype.', { name }))
+				throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
 			} else if (this.checkIfNodeExists(name)) {
-				throw new Error(this.t('files', '{newName} already exists.', { newName: name }))
+				throw new Error(t('files', '{newName} already exists.', { newName: name }))
 			}
 
 			const toCheck = trimmedName.split('')
@@ -859,7 +860,7 @@ export default Vue.extend({
 			const oldEncodedSource = this.source.encodedSource
 			const newName = this.newName.trim?.() || ''
 			if (newName === '') {
-				showError(this.t('files', 'Name cannot be empty'))
+				showError(t('files', 'Name cannot be empty'))
 				return
 			}
 
@@ -870,7 +871,7 @@ export default Vue.extend({
 
 			// Checking if already exists
 			if (this.checkIfNodeExists(newName)) {
-				showError(this.t('files', 'Another entry with the same name already exists'))
+				showError(t('files', 'Another entry with the same name already exists'))
 				return
 			}
 
@@ -894,7 +895,7 @@ export default Vue.extend({
 				// Success 🎉
 				emit('files:node:updated', this.source)
 				emit('files:node:renamed', this.source)
-				showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
+				showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
 
 				// Reset the renaming store
 				this.stopRenaming()
@@ -908,15 +909,15 @@ export default Vue.extend({
 
 				// TODO: 409 means current folder does not exist, redirect ?
 				if (error?.response?.status === 404) {
-					showError(this.t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
+					showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
 					return
 				} else if (error?.response?.status === 412) {
-					showError(this.t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
+					showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
 					return
 				}
 
 				// Unknown error
-				showError(this.t('files', 'Could not rename "{oldName}"', { oldName }))
+				showError(t('files', 'Could not rename "{oldName}"', { oldName }))
 			} finally {
 				this.loading = false
 				Vue.set(this.source, 'status', undefined)
@@ -945,8 +946,6 @@ export default Vue.extend({
 		onDragOver(event: DragEvent) {
 			this.dragover = this.canDrop
 			if (!this.canDrop) {
-				event.preventDefault()
-				event.stopPropagation()
 				event.dataTransfer.dropEffect = 'none'
 				return
 			}
@@ -959,9 +958,13 @@ export default Vue.extend({
 			}
 		},
 		onDragLeave(event: DragEvent) {
-			if (this.$el.contains(event.target) && event.target !== this.$el) {
+			// 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
 		},
 
@@ -990,7 +993,7 @@ export default Vue.extend({
 				.map(fileid => this.filesStore.getNode(fileid)) as Node[]
 
 			const image = await getDragAndDropPreview(nodes)
-			event.dataTransfer.setDragImage(image, -10, -10)
+			event.dataTransfer?.setDragImage(image, -10, -10)
 		},
 		onDragEnd() {
 			this.draggingStore.reset()
@@ -999,6 +1002,9 @@ export default Vue.extend({
 		},
 
 		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) {
@@ -1010,6 +1016,16 @@ export default Vue.extend({
 
 			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)
@@ -1019,9 +1035,9 @@ export default Vue.extend({
 				} catch (error) {
 					logger.error('Error while moving file', { error })
 					if (isCopy) {
-						showError(this.t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
+						showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
 					} else {
-						showError(this.t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
+						showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
 					}
 				} finally {
 					Vue.set(node, 'status', undefined)
@@ -1036,7 +1052,7 @@ export default Vue.extend({
 			}
 		},
 
-		t: translate,
+		t,
 		formatFileSize,
 	},
 })

+ 0 - 1
apps/files/src/components/FilesListFooter.vue

@@ -159,7 +159,6 @@ export default Vue.extend({
 <style scoped lang="scss">
 // Scoped row
 tr {
-	padding-bottom: 300px;
 	border-top: 1px solid var(--color-border);
 	// Prevent hover effect on the whole row
 	background-color: transparent !important;

+ 1 - 1
apps/files/src/components/FilesListTableHeaderButton.vue

@@ -22,7 +22,7 @@
 <template>
 	<NcButton :aria-label="sortAriaLabel(name)"
 		:class="{'files-list__column-sort-button--active': sortingMode === mode}"
-		:alignment="mode !== 'size' ? 'start-reverse' : ''"
+		:alignment="mode !== 'size' ? 'start-reverse' : 'center'"
 		class="files-list__column-sort-button"
 		type="tertiary"
 		@click.stop.prevent="toggleSortBy(mode)">

+ 133 - 61
apps/files/src/components/FilesListVirtual.vue

@@ -20,62 +20,76 @@
   -
   -->
 <template>
-	<VirtualList :data-component="FileEntry"
-		:data-key="'source'"
-		:data-sources="nodes"
-		:item-height="56"
-		:extra-props="{
-			isMtimeAvailable,
-			isSizeAvailable,
-			nodes,
-			filesListWidth,
-		}"
-		:scroll-to-index="scrollToIndex">
-		<!-- Accessibility description and headers -->
-		<template #before>
-			<!-- Accessibility description -->
-			<caption class="hidden-visually">
-				{{ currentView.caption || t('files', 'List of files and folders.') }}
-				{{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
-			</caption>
-
-			<!-- Headers -->
-			<FilesListHeader v-for="header in sortedHeaders"
-				:key="header.id"
-				:current-folder="currentFolder"
-				:current-view="currentView"
-				:header="header" />
-		</template>
-
-		<!-- Thead-->
-		<template #header>
-			<FilesListTableHeader :files-list-width="filesListWidth"
-				:is-mtime-available="isMtimeAvailable"
-				:is-size-available="isSizeAvailable"
-				:nodes="nodes" />
-		</template>
-
-		<!-- Tfoot-->
-		<template #footer>
-			<FilesListTableFooter :files-list-width="filesListWidth"
-				:is-mtime-available="isMtimeAvailable"
-				:is-size-available="isSizeAvailable"
-				:nodes="nodes"
-				:summary="summary" />
-		</template>
-	</VirtualList>
+	<Fragment>
+		<!-- Drag and drop notice -->
+		<DragAndDropNotice v-if="canUpload && filesListWidth >= 512"
+			:current-folder="currentFolder"
+			:dragover.sync="dragover"
+			:style="{ height: dndNoticeHeight }" />
+
+		<VirtualList ref="table"
+			:data-component="FileEntry"
+			:data-key="'source'"
+			:data-sources="nodes"
+			:item-height="56"
+			:extra-props="{
+				isMtimeAvailable,
+				isSizeAvailable,
+				nodes,
+				filesListWidth,
+			}"
+			:scroll-to-index="scrollToIndex"
+			@scroll="onScroll">
+			<!-- Accessibility description and headers -->
+			<template #before>
+				<!-- Accessibility description -->
+				<caption class="hidden-visually">
+					{{ currentView.caption || t('files', 'List of files and folders.') }}
+					{{ t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') }}
+				</caption>
+
+				<!-- Headers -->
+				<FilesListHeader v-for="header in sortedHeaders"
+					:key="header.id"
+					:current-folder="currentFolder"
+					:current-view="currentView"
+					:header="header" />
+			</template>
+
+			<!-- Thead-->
+			<template #header>
+				<!-- Table header and sort buttons -->
+				<FilesListTableHeader ref="thead"
+					:files-list-width="filesListWidth"
+					:is-mtime-available="isMtimeAvailable"
+					:is-size-available="isSizeAvailable"
+					:nodes="nodes" />
+			</template>
+
+			<!-- Tfoot-->
+			<template #footer>
+				<FilesListTableFooter :files-list-width="filesListWidth"
+					:is-mtime-available="isMtimeAvailable"
+					:is-size-available="isSizeAvailable"
+					:nodes="nodes"
+					:summary="summary" />
+			</template>
+		</VirtualList>
+	</Fragment>
 </template>
 
 <script lang="ts">
 import type { PropType } from 'vue'
-import type { Node } from '@nextcloud/files'
+import type { Node as NcNode } from '@nextcloud/files'
 
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import { getFileListHeaders, Folder, View } from '@nextcloud/files'
+import { Fragment } from 'vue-frag'
+import { getFileListHeaders, Folder, View, Permission } from '@nextcloud/files'
 import { showError } from '@nextcloud/dialogs'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
 import Vue from 'vue'
 
 import { action as sidebarAction } from '../actions/sidebarAction.ts'
+import DragAndDropNotice from './DragAndDropNotice.vue'
 import FileEntry from './FileEntry.vue'
 import FilesListHeader from './FilesListHeader.vue'
 import FilesListTableFooter from './FilesListTableFooter.vue'
@@ -88,9 +102,11 @@ export default Vue.extend({
 	name: 'FilesListVirtual',
 
 	components: {
+		DragAndDropNotice,
 		FilesListHeader,
-		FilesListTableHeader,
 		FilesListTableFooter,
+		FilesListTableHeader,
+		Fragment,
 		VirtualList,
 	},
 
@@ -108,7 +124,7 @@ export default Vue.extend({
 			required: true,
 		},
 		nodes: {
-			type: Array as PropType<Node[]>,
+			type: Array as PropType<NcNode[]>,
 			required: true,
 		},
 	},
@@ -118,6 +134,8 @@ export default Vue.extend({
 			FileEntry,
 			headers: getFileListHeaders(),
 			scrollToIndex: 0,
+			dragover: false,
+			dndNoticeHeight: 0,
 		}
 	},
 
@@ -163,9 +181,18 @@ export default Vue.extend({
 
 			return [...this.headers].sort((a, b) => a.order - b.order)
 		},
+
+		canUpload() {
+			return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
+		},
 	},
 
 	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)
+		mainContent.addEventListener('dragleave', this.onDragLeave)
+
 		// Scroll to the file if it's in the url
 		if (this.fileId) {
 			const index = this.nodes.findIndex(node => node.fileid === this.fileId)
@@ -176,15 +203,11 @@ export default Vue.extend({
 		}
 
 		// Open the file sidebar if we have the room for it
-		if (document.documentElement.clientWidth > 1024) {
-			// Don't open the sidebar for the current folder
-			if (this.currentFolder.fileid === this.fileId) {
-				return
-			}
-
+		// but don't open the sidebar for the current folder
+		if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== this.fileId) {
 			// Open the sidebar for the given URL fileid
 			// iif we just loaded the app.
-			const node = this.nodes.find(n => n.fileid === this.fileId) as Node
+			const node = this.nodes.find(n => n.fileid === this.fileId) as NcNode
 			if (node && sidebarAction?.enabled?.([node], this.currentView)) {
 				logger.debug('Opening sidebar on file ' + node.path, { node })
 				sidebarAction.exec(node, this.currentView, this.currentFolder.path)
@@ -197,6 +220,49 @@ export default Vue.extend({
 			return node.fileid
 		},
 
+		onDragOver(event: DragEvent) {
+			// Detect if we're only dragging existing files or not
+			const isForeignFile = event.dataTransfer?.types.includes('Files')
+			if (isForeignFile) {
+				this.dragover = true
+			} else {
+				this.dragover = false
+			}
+
+			event.preventDefault()
+			event.stopPropagation()
+
+			// If reaching top, scroll up
+			const firstVisible = this.$refs.table?.$el?.querySelector('.files-list__row--visible') as HTMLElement
+			const firstSibling = firstVisible?.previousElementSibling as HTMLElement
+			if ([firstVisible, firstSibling].some(elmt => elmt?.contains(event.target as Node))) {
+				this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop - 25
+				return
+			}
+
+			// If reaching bottom, scroll down
+			const lastVisible = [...(this.$refs.table?.$el?.querySelectorAll('.files-list__row--visible') || [])].pop() as HTMLElement
+			const nextSibling = lastVisible?.nextElementSibling as HTMLElement
+			if ([lastVisible, nextSibling].some(elmt => elmt?.contains(event.target as Node))) {
+				this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop + 25
+			}
+		},
+		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
+		},
+
+		onScroll() {
+			// Update the sticky position of the thead to adapt to the scroll
+			this.dndNoticeHeight = (this.$refs.thead.$el?.getBoundingClientRect?.()?.top ?? 0) + 'px'
+		},
+
 		t,
 	},
 })
@@ -232,6 +298,15 @@ export default Vue.extend({
 			flex-direction: column;
 		}
 
+		.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
@@ -240,12 +315,9 @@ export default Vue.extend({
 			top: 0;
 		}
 
-		.files-list__thead,
+		// Table footer
 		.files-list__tfoot {
-			display: flex;
-			width: 100%;
-			background-color: var(--color-main-background);
-
+			min-height: 300px;
 		}
 
 		tr {

+ 1 - 4
apps/files/src/components/VirtualList.vue

@@ -152,11 +152,8 @@ export default Vue.extend({
 		onScroll() {
 			// Max 0 to prevent negative index
 			this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
+			this.$emit('scroll')
 		},
 	},
 })
 </script>
-
-<style scoped>
-
-</style>

+ 1 - 0
apps/files/src/views/FilesList.vue

@@ -437,6 +437,7 @@ export default Vue.extend({
 	overflow: hidden;
 	flex-direction: column;
 	max-height: 100%;
+	position: relative;
 }
 
 $margin: 4px;

+ 4 - 0
cypress/support/e2e.ts

@@ -20,3 +20,7 @@
  *
  */
 import './commands.ts'
+
+// Fix ResizeObserver loop limit exceeded happening in Cypress only
+// @see https://github.com/cypress-io/cypress/issues/20341
+Cypress.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop limit exceeded'))

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/614-614.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/614-614.js.map


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/core-unsupported-browser.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/core-unsupported-browser.js.map


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/files-main.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/files-main.js.map


Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott