diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2024-02-01 19:35:07 +0100 |
---|---|---|
committer | nextcloud-command <nextcloud-command@users.noreply.github.com> | 2024-02-07 07:57:23 +0000 |
commit | 6e0499461dbcd2a9c43ef0d407e037e6c5f27a81 (patch) | |
tree | 9dbc26a6b43cdf01448a8c5088002937db0a22a5 /apps/files | |
parent | 97cd038cf20c5015d9dfecd0e9283367391358d2 (diff) | |
download | nextcloud-server-6e0499461dbcd2a9c43ef0d407e037e6c5f27a81.tar.gz nextcloud-server-6e0499461dbcd2a9c43ef0d407e037e6c5f27a81.zip |
chore(files): move shared FileEntry and FileEntryGrid into a mixin
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 377 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryActions.vue | 2 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryGrid.vue | 364 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 409 |
4 files changed, 425 insertions, 727 deletions
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index dc41e5b8b93..274656f5d70 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -96,37 +96,17 @@ </template> <script lang="ts"> -import type { PropType } from 'vue' - -import { extname, join } from 'path' -import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' -import { Upload, getUploader } from '@nextcloud/upload' -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { vOnClickOutside } from '@vueuse/components' +import { defineComponent } from 'vue' +import { formatFileSize } from '@nextcloud/files' import moment from '@nextcloud/moment' -import { generateUrl } from '@nextcloud/router' -import Vue, { defineComponent } 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 FileEntryMixin from './FileEntryMixin.ts' import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' import CustomElementRender from './CustomElementRender.vue' 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 defineComponent({ name: 'FileEntry', @@ -140,6 +120,10 @@ export default defineComponent({ NcDateTime, }, + mixins: [ + FileEntryMixin, + ], + props: { isMtimeAvailable: { type: Boolean, @@ -149,46 +133,12 @@ export default defineComponent({ 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, - }, compact: { type: Boolean, default: false, }, }, - 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: { /** * Conditionally add drag and drop listeners @@ -210,9 +160,6 @@ export default defineComponent({ drop: this.onDrop, } }, - currentView(): View { - return this.$navigation.active as View - }, columns() { // Hide columns if the list is too small if (this.filesListWidth < 512 || this.compact) { @@ -221,42 +168,10 @@ export default defineComponent({ return this.currentView?.columns || [] }, - 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 - }, - 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) - }, - size() { const size = parseInt(this.source.size, 10) || 0 if (typeof size !== 'number' || size < 0) { - return t('files', 'Pending') + return this.t('files', 'Pending') } return formatFileSize(size, true) }, @@ -296,285 +211,9 @@ export default defineComponent({ } return '' }, - - draggingFiles() { - return this.draggingStore.dragging - }, - selectedFiles() { - return this.selectionStore.selected - }, - isSelected() { - return this.fileid && this.selectedFiles.includes(this.fileid) - }, - - isRenaming() { - return this.renamingStore.renamingNode === this.source - }, - isRenamingSmallScreen() { - return this.isRenaming && this.filesListWidth < 512 - }, - - isActive() { - return this.fileid?.toString?.() === this.currentFileId?.toString?.() - }, - - 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(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.fileid && this.draggingFiles.includes(this.fileid)) { - return false - } - - return (this.source.permissions & Permission.CREATE) !== 0 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId.toString() - }, - set(opened) { - // Only reset when opening a new menu - if (opened) { - // Reset any right click position override on close - // Wait for css animation to be done - const root = this.$root.$el as HTMLElement - root.style.removeProperty('--mouse-pos-x') - root.style.removeProperty('--mouse-pos-y') - } - - this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : 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 - } - - const root = this.$root.$el 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(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px') - root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px') - - // 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) { - if (event.ctrlKey || event.metaKey) { - event.preventDefault() - window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) - return false - } - - this.$refs.actions.execDefaultAction(event) - }, - - 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.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: DragEvent) { - // skip if native drop like text drag and drop from files names - if (!this.draggingFiles && !event.dataTransfer?.files?.length) { - return - } - - 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 - && event.dataTransfer.files.length > 0) { - const uploader = getUploader() - - // Check whether the uploader is in the same folder - // This should never happen™ - if (!uploader.destination.path.startsWith(uploader.destination.path)) { - logger.error('The current uploader destination is not the same as the current folder') - showError(t('files', 'An error occurred while uploading. Please try again later.')) - return - } - - logger.debug(`Uploading files to ${this.source.path}`) - const queue = [] as Promise<Upload>[] - for (const file of event.dataTransfer.files) { - // Because the uploader destination is properly set to the current folder - // we can just use the basename as the relative path. - queue.push(uploader.upload(join(this.source.basename, file.name), file)) - } - - const results = await Promise.allSettled(queue) - const errors = results.filter(result => result.status === 'rejected') - if (errors.length > 0) { - logger.error('Error while uploading files', { errors }) - showError(t('files', 'Some files could not be uploaded')) - return - } - - logger.debug('Files uploaded successfully') - showSuccess(t('files', 'Files uploaded successfully')) - 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, formatFileSize, }, }) diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index d9d8cefdbad..c46990e971b 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -346,7 +346,7 @@ export default Vue.extend({ <style lang="scss"> // Allow right click to define the position of the menu // only if defined -.app-content[style*="mouse-pos-x"] .v-popper__popper { +[style*="mouse-pos-x"] .v-popper__popper { transform: translate3d(var(--mouse-pos-x), var(--mouse-pos-y), 0px) !important; // If the menu is too close to the bottom, we move it up diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 30a5e2d94e3..1d10f3d2948 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -73,34 +73,13 @@ </template> <script lang="ts"> -import type { PropType } from 'vue' +import { defineComponent } from 'vue' -import { extname, join } from 'path' -import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' -import { Upload, getUploader } from '@nextcloud/upload' -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' -import { vOnClickOutside } from '@vueuse/components' -import Vue, { defineComponent } 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 FileEntryMixin from './FileEntryMixin.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 defineComponent({ name: 'FileEntryGrid', @@ -112,345 +91,16 @@ export default defineComponent({ FileEntryPreview, }, - inheritAttrs: false, - props: { - source: { - type: [Folder, NcFile, Node] as PropType<Node>, - required: true, - }, - nodes: { - type: Array as PropType<Node[]>, - required: true, - }, - filesListWidth: { - type: Number, - default: 0, - }, - }, + mixins: [ + FileEntryMixin, + ], - setup() { - const actionsMenuStore = useActionsMenuStore() - const draggingStore = useDragAndDropStore() - const filesStore = useFilesStore() - const renamingStore = useRenamingStore() - const selectionStore = useSelectionStore() - return { - actionsMenuStore, - draggingStore, - filesStore, - renamingStore, - selectionStore, - } - }, + inheritAttrs: false, data() { return { - loading: '', - dragover: false, + gridMode: true, } }, - - 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 - }, - 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.fileid && this.selectedFiles.includes(this.fileid) - }, - - isRenaming() { - return this.renamingStore.renamingNode === this.source - }, - - isActive() { - return this.fileid?.toString?.() === 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.fileid && this.draggingFiles.includes(this.fileid)) { - return false - } - - return (this.source.permissions & Permission.CREATE) !== 0 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId.toString() - }, - set(opened) { - // Only reset when opening a new menu - if (opened) { - // Reset any right click position override on close - // Wait for css animation to be done - const root = this.$root.$el as HTMLElement - root.style.removeProperty('--mouse-pos-x') - root.style.removeProperty('--mouse-pos-y') - } - - this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : 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(event) { - if (event.ctrlKey || event.metaKey) { - event.preventDefault() - window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) - return false - } - - this.$refs.actions.execDefaultAction(event) - }, - - 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.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: DragEvent) { - // skip if native drop like text drag and drop from files names - if (!this.draggingFiles && !event.dataTransfer?.files?.length) { - return - } - - 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 - && event.dataTransfer.files.length > 0) { - const uploader = getUploader() - - // Check whether the uploader is in the same folder - // This should never happen™ - if (!uploader.destination.path.startsWith(uploader.destination.path)) { - logger.error('The current uploader destination is not the same as the current folder') - showError(t('files', 'An error occurred while uploading. Please try again later.')) - return - } - - logger.debug(`Uploading files to ${this.source.path}`) - const queue = [] as Promise<Upload>[] - for (const file of event.dataTransfer.files) { - // Because the uploader destination is properly set to the current folder - // we can just use the basename as the relative path. - queue.push(uploader.upload(join(this.source.basename, file.name), file)) - } - - const results = await Promise.allSettled(queue) - const errors = results.filter(result => result.status === 'rejected') - if (errors.length > 0) { - logger.error('Error while uploading files', { errors }) - showError(t('files', 'Some files could not be uploaded')) - return - } - - logger.debug('Files uploaded successfully') - showSuccess(t('files', 'Files uploaded successfully')) - 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> diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts new file mode 100644 index 00000000000..68320c80733 --- /dev/null +++ b/apps/files/src/components/FileEntryMixin.ts @@ -0,0 +1,409 @@ +/** + * @copyright Copyright (c) 2024 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/>. + * + */ + +import type { PropType } from 'vue' + +import { extname, join } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { Upload, getUploader } from '@nextcloud/upload' +import { vOnClickOutside } from '@vueuse/components' +import Vue, { defineComponent } from 'vue' + +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 { 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 logger from '../logger.js' + +Vue.directive('onClickOutside', vOnClickOutside) + +export default defineComponent({ + props: { + 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, + gridMode: 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 + }, + 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.fileid && this.selectedFiles.includes(this.fileid) + }, + + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + + isActive() { + return this.fileid?.toString?.() === this.currentFileId?.toString?.() + }, + + 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(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.fileid && this.draggingFiles.includes(this.fileid)) { + return false + } + + return (this.source.permissions & Permission.CREATE) !== 0 + }, + + openedMenu: { + get() { + return this.actionsMenuStore.opened === this.uniqueId.toString() + }, + set(opened) { + // Only reset when opening a new menu + if (opened) { + // Reset any right click position override on close + // Wait for css animation to be done + const root = this.$root.$el as HTMLElement + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + + this.actionsMenuStore.opened = opened ? this.uniqueId.toString() : 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 + } + + // The grid mode is compact enough to not care about + // the actions menu mouse position + if (!this.gridMode) { + const root = this.$root.$el 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(contentRect.left, Math.min(event.clientX, event.clientX - 200)) + 'px') + root.style.setProperty('--mouse-pos-y', Math.max(contentRect.top, event.clientY - contentRect.top) + 'px') + } + + // 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) { + if (event.ctrlKey || event.metaKey) { + event.preventDefault() + window.open(generateUrl('/f/{fileId}', { fileId: this.fileid })) + return false + } + + this.$refs.actions.execDefaultAction(event) + }, + + 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.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: DragEvent) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.files?.length) { + return + } + + 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 + && event.dataTransfer.files.length > 0) { + const uploader = getUploader() + + // Check whether the uploader is in the same folder + // This should never happen™ + if (!uploader.destination.path.startsWith(uploader.destination.path)) { + logger.error('The current uploader destination is not the same as the current folder') + showError(t('files', 'An error occurred while uploading. Please try again later.')) + return + } + + logger.debug(`Uploading files to ${this.source.path}`) + const queue = [] as Promise<Upload>[] + for (const file of event.dataTransfer.files) { + // Because the uploader destination is properly set to the current folder + // we can just use the basename as the relative path. + queue.push(uploader.upload(join(this.source.basename, file.name), file)) + } + + const results = await Promise.allSettled(queue) + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while uploading files', { errors }) + showError(t('files', 'Some files could not be uploaded')) + return + } + + logger.debug('Files uploaded successfully') + showSuccess(t('files', 'Files uploaded successfully')) + 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, + }, +}) |