feat: allow external drop and add dropzone

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/40674/head
John Molakvoæ 8 months ago
parent 9de246d74f
commit 35aed73ede
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -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);
}
}

@ -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>

@ -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,
},
})

@ -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;

@ -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)">

@ -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 {

@ -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>

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

@ -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'))

4
dist/614-614.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save