/** * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { PropType } from 'vue' import type { FileSource } from '../types.ts' import { showError } from '@nextcloud/dialogs' import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { vOnClickOutside } from '@vueuse/components' import { extname } from 'path' import Vue, { defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' import { hashCode } from '../utils/hashUtils.ts' import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' import logger from '../logger.ts' Vue.directive('onClickOutside', vOnClickOutside) const actions = getFileActions() export default defineComponent({ props: { source: { type: [Folder, NcFile, Node] as PropType, required: true, }, nodes: { type: Array as PropType, required: true, }, filesListWidth: { type: Number, default: 0, }, isMtimeAvailable: { type: Boolean, default: false, }, compact: { type: Boolean, default: false, }, }, provide() { return { defaultFileAction: this.defaultFileAction, enabledFileActions: this.enabledFileActions, } }, data() { return { loading: '', dragover: false, gridMode: false, } }, computed: { fileid() { return this.source.fileid ?? 0 }, uniqueId() { return hashCode(this.source.source) }, isLoading() { return this.source.status === NodeStatus.LOADING || this.loading !== '' }, /** * The display name of the current node * Either the nodes filename or a custom display name (e.g. for shares) */ displayName() { // basename fallback needed for apps using old `@nextcloud/files` prior 3.6.0 return this.source.displayname || this.source.basename }, /** * The display name without extension */ basename() { if (this.extension === '') { return this.displayName } return this.displayName.slice(0, 0 - this.extension.length) }, /** * The extension of the file */ extension() { if (this.source.type === FileType.Folder) { return '' } return extname(this.displayName) }, draggingFiles() { return this.draggingStore.dragging as FileSource[] }, selectedFiles() { return this.selectionStore.selected as FileSource[] }, isSelected() { return this.selectedFiles.includes(this.source.source) }, isRenaming() { return this.renamingStore.renamingNode === this.source }, isRenamingSmallScreen() { return this.isRenaming && this.filesListWidth < 512 }, isActive() { return String(this.fileid) === String(this.currentFileId) }, /** * Check if the source is in a failed state after an API request */ isFailedSource() { return this.source.status === NodeStatus.FAILED }, canDrag() { if (this.isRenaming) { return false } 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(source => this.filesStore.getNode(source)) 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.source.source)) { return false } return (this.source.permissions & Permission.CREATE) !== 0 }, openedMenu: { get() { return this.actionsMenuStore.opened === this.uniqueId.toString() }, set(opened) { this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : null }, }, mtimeOpacity() { const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days const mtime = this.source.mtime?.getTime?.() if (!mtime) { return {} } // 1 = today, 0 = 31 days ago const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime)) if (ratio < 0) { return {} } return { color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, } }, /** * Sorted actions that are enabled for this node */ enabledFileActions() { if (this.source.status === NodeStatus.FAILED) { return [] } return actions .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) .sort((a, b) => (a.order || 0) - (b.order || 0)) }, defaultFileAction() { return this.enabledFileActions.find((action) => action.default !== undefined) }, }, watch: { /** * When the source changes, reset the preview * and fetch the new one. * @param a * @param b */ source(a: Node, b: Node) { if (a.source !== b.source) { this.resetState() } }, openedMenu() { if (this.openedMenu === false) { // TODO: This timeout can be removed once `close` event only triggers after the transition // ref: https://github.com/nextcloud-libraries/nextcloud-vue/pull/6065 window.setTimeout(() => { if (this.openedMenu) { // was reopened while the animation run return } // Reset any right menu position potentially set const root = document.getElementById('app-content-vue') if (root !== null) { root.style.removeProperty('--mouse-pos-x') root.style.removeProperty('--mouse-pos-y') } }, 300) } }, }, beforeDestroy() { this.resetState() }, methods: { resetState() { // Reset loading state this.loading = '' // Reset the preview state 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 } // The grid mode is compact enough to not care about // the actions menu mouse position if (!this.gridMode) { // Actions menu is contained within the app content const root = this.$el?.closest('main.app-content') as HTMLElement const contentRect = root.getBoundingClientRect() // Using Math.min/max to prevent the menu from going out of the AppContent // 200 = max width of the menu root.style.setProperty('--mouse-pos-x', Math.max(0, event.clientX - contentRect.left - 200) + 'px') root.style.setProperty('--mouse-pos-y', Math.max(0, event.clientY - contentRect.top) + 'px') } else { // Reset any right menu position potentially set const root = this.$el?.closest('main.app-content') as HTMLElement root.style.removeProperty('--mouse-pos-x') root.style.removeProperty('--mouse-pos-y') } // 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.toString() // Prevent any browser defaults event.preventDefault() event.stopPropagation() }, execDefaultAction(event) { // Ignore click if we are renaming if (this.isRenaming) { return } // Ignore right click. if (event.button > 1) { return } // if ctrl+click or middle mouse button, open in new tab if (event.ctrlKey || event.metaKey || event.button === 1) { event.preventDefault() window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) return false } if (this.defaultFileAction) { event.preventDefault() event.stopPropagation() // Execute the first default action if any this.defaultFileAction.exec(this.source, this.currentView, this.currentDir) } else { // fallback to open in current tab window.open(generateUrl('/f/{fileId}', { fileId: this.fileid }), '_self') } }, 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 || !this.fileid) { event.preventDefault() event.stopPropagation() return } logger.debug('Drag started', { event }) // Make sure that we're not dragging a file like the preview event.dataTransfer?.clearData?.() // 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.source.source)) { this.draggingStore.set(this.selectedFiles) } else { this.draggingStore.set([this.source.source]) } const nodes = this.draggingStore.dragging .map(source => this.filesStore.getNode(source)) 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: DragEvent) { // skip if native drop like text drag and drop from files names if (!this.draggingFiles && !event.dataTransfer?.items?.length) { return } event.preventDefault() event.stopPropagation() // Caching the selection const selection = this.draggingFiles const items = [...event.dataTransfer?.items || []] as DataTransferItem[] // We need to process the dataTransfer ASAP before the // browser clears it. This is why we cache the items too. const fileTree = await dataTransferToFileTree(items) // We might not have the target directory fetched yet const contents = await this.currentView?.getContents(this.source.path) const folder = contents?.folder if (!folder) { showError(this.t('files', 'Target folder does not exist any more')) return } // If another button is pressed, cancel it. This // allows cancelling the drag with the right click. if (!this.canDrop || event.button) { return } const isCopy = event.ctrlKey this.dragover = false logger.debug('Dropped', { event, folder, selection, fileTree }) // Check whether we're uploading files if (fileTree.contents.length > 0) { await onDropExternalFiles(fileTree, folder, contents.contents) return } // Else we're moving/copying files const nodes = selection.map(source => this.filesStore.getNode(source)) as Node[] await onDropInternalFiles(nodes, folder, contents.contents, isCopy) // Reset selection after we dropped the files // if the dropped files are within the selection if (selection.some(source => this.selectedFiles.includes(source))) { logger.debug('Dropped selection, resetting select store...') this.selectionStore.reset() } }, t, }, })