diff options
Diffstat (limited to 'apps/files/src/components')
38 files changed, 3651 insertions, 1882 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index f50a4d14fd8..8458fd65f3d 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -1,40 +1,28 @@ <!-- - - @copyright Copyright (c) 2023 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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcBreadcrumbs - data-cy-files-content-breadcrumbs - :aria-label="t('files', 'Current directory path')"> + <NcBreadcrumbs data-cy-files-content-breadcrumbs + :aria-label="t('files', 'Current directory path')" + class="files-list__breadcrumbs" + :class="{ 'files-list__breadcrumbs--with-progress': wrapUploadProgressBar }"> <!-- Current path sections --> <NcBreadcrumb v-for="(section, index) in sections" :key="section.dir" v-bind="section" dir="auto" :to="section.to" + :force-icon-text="index === 0 && fileListWidth >= 486" :title="titleForSection(index, section)" :aria-description="ariaForSection(section)" - @click.native="onClick(section.to)"> + @click.native="onClick(section.to)" + @dragover.native="onDragOver($event, section.dir)" + @drop="onDrop($event, section.dir)"> <template v-if="index === 0" #icon> - <Home :size="20"/> + <NcIconSvgWrapper :size="20" + :svg="viewIcon" /> </template> </NcBreadcrumb> @@ -47,24 +35,35 @@ <script lang="ts"> import type { Node } from '@nextcloud/files' +import type { FileSource } from '../types.ts' -import { translate as t} from '@nextcloud/l10n' import { basename } from 'path' -import Home from 'vue-material-design-icons/Home.vue' -import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' -import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' import { defineComponent } from 'vue' +import { Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import HomeSvg from '@mdi/svg/svg/home.svg?raw' +import NcBreadcrumb from '@nextcloud/vue/components/NcBreadcrumb' +import NcBreadcrumbs from '@nextcloud/vue/components/NcBreadcrumbs' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { useNavigation } from '../composables/useNavigation.ts' +import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { showError } from '@nextcloud/dialogs' +import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' +import { useUploaderStore } from '../store/uploader.ts' +import logger from '../logger' export default defineComponent({ name: 'BreadCrumbs', components: { - Home, NcBreadcrumbs, NcBreadcrumb, + NcIconSvgWrapper, }, props: { @@ -75,19 +74,28 @@ export default defineComponent({ }, setup() { + const draggingStore = useDragAndDropStore() const filesStore = useFilesStore() const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() + const uploaderStore = useUploaderStore() + const fileListWidth = useFileListWidth() + const { currentView, views } = useNavigation() + return { + draggingStore, filesStore, pathsStore, + selectionStore, + uploaderStore, + + currentView, + fileListWidth, + views, } }, computed: { - currentView() { - return this.$navigation.active - }, - dirs(): string[] { const cumulativePath = (acc: string) => (value: string) => (acc += `${value}/`) // Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc @@ -97,34 +105,83 @@ export default defineComponent({ }, sections() { - return this.dirs.map((dir: string) => { - const fileid = this.getFileIdFromPath(dir) - const to = { ...this.$route, params: { fileid }, query: { dir } } + return this.dirs.map((dir: string, index: number) => { + const source = this.getFileSourceFromPath(dir) + const node: Node | undefined = source ? this.getNodeFromSource(source) : undefined return { dir, exact: true, name: this.getDirDisplayName(dir), - to, + to: this.getTo(dir, node), + // disable drop on current directory + disableDrop: index === this.dirs.length - 1, } }) }, + + isUploadInProgress(): boolean { + return this.uploaderStore.queue.length !== 0 + }, + + // Hide breadcrumbs if an upload is ongoing + wrapUploadProgressBar(): boolean { + // if an upload is ongoing, and on small screens / mobile, then + // show the progress bar for the upload below breadcrumbs + return this.isUploadInProgress && this.fileListWidth < 512 + }, + + // used to show the views icon for the first breadcrumb + viewIcon(): string { + return this.currentView?.icon ?? HomeSvg + }, + + selectedFiles() { + return this.selectionStore.selected as FileSource[] + }, + + draggingFiles() { + return this.draggingStore.dragging as FileSource[] + }, }, methods: { - getNodeFromId(id: number): Node | undefined { - return this.filesStore.getNode(id) + getNodeFromSource(source: FileSource): Node | undefined { + return this.filesStore.getNode(source) }, - getFileIdFromPath(path: string): number | undefined { - return this.pathsStore.getPath(this.currentView?.id, path) + getFileSourceFromPath(path: string): FileSource | null { + return (this.currentView && this.pathsStore.getPath(this.currentView.id, path)) ?? null }, getDirDisplayName(path: string): string { if (path === '/') { - return t('files', 'Home') + return this.currentView?.name || t('files', 'Home') } - const fileId: number | undefined = this.getFileIdFromPath(path) - const node: Node | undefined = (fileId) ? this.getNodeFromId(fileId) : undefined - return node?.attributes?.displayName || basename(path) + const source = this.getFileSourceFromPath(path) + const node = source ? this.getNodeFromSource(source) : undefined + return node?.displayname || basename(path) + }, + + getTo(dir: string, node?: Node): Record<string, unknown> { + if (dir === '/') { + return { + ...this.$route, + params: { view: this.currentView?.id }, + query: {}, + } + } + if (node === undefined) { + const view = this.views.find(view => view.params?.dir === dir) + return { + ...this.$route, + params: { fileid: view?.params?.fileid ?? '' }, + query: { dir }, + } + } + return { + ...this.$route, + params: { fileid: String(node.fileid) }, + query: { dir: node.path }, + } }, onClick(to) { @@ -133,6 +190,81 @@ export default defineComponent({ } }, + onDragOver(event: DragEvent, path: string) { + if (!event.dataTransfer) { + return + } + + // Cannot drop on the current directory + if (path === this.dirs[this.dirs.length - 1]) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + + async onDrop(event: DragEvent, path: string) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + // Do not stop propagation, so the main content + // drop event can be triggered too and clear the + // dragover state on the DragAndDropNotice component. + event.preventDefault() + + // 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(path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + const canDrop = (folder.permissions & Permission.CREATE) !== 0 + const isCopy = event.ctrlKey + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!canDrop || event.button !== 0) { + return + } + + 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() + } + }, + titleForSection(index, section) { if (section?.to?.query?.dir === this.$route.query.dir) { return t('files', 'Reload current directory') @@ -155,14 +287,24 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.breadcrumb { +.files-list__breadcrumbs { // Take as much space as possible flex: 1 1 100% !important; width: 100%; + height: 100%; + margin-block: 0; + margin-inline: 10px; + min-width: 0; + + :deep() { + a { + cursor: pointer !important; + } + } - ::v-deep a { - cursor: pointer !important; + &--with-progress { + flex-direction: column !important; + align-items: flex-start !important; } } - </style> diff --git a/apps/files/src/components/CustomElementRender.vue b/apps/files/src/components/CustomElementRender.vue index 66774bb52a0..b08d3ba5ee5 100644 --- a/apps/files/src/components/CustomElementRender.vue +++ b/apps/files/src/components/CustomElementRender.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <span /> </template> diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index 22de0f662de..c7684d5c205 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -1,27 +1,10 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-show="dragover" + data-cy-files-drag-drop-area class="files-list__drag-drop-notice" @drop="onDrop"> <div class="files-list__drag-drop-notice-wrapper"> @@ -43,15 +26,21 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue' -import { Folder, Permission } from '@nextcloud/files' -import { showError, showSuccess } from '@nextcloud/dialogs' +import type { Folder } from '@nextcloud/files' + +import { Permission } from '@nextcloud/files' +import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import { UploadStatus } from '@nextcloud/upload' +import { defineComponent, type PropType } from 'vue' +import debounce from 'debounce' import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' -import logger from '../logger.js' -import { handleDrop } from '../services/DropService' +import { useNavigation } from '../composables/useNavigation' +import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import logger from '../logger.ts' +import type { RawLocation } from 'vue-router' export default defineComponent({ name: 'DragAndDropNotice', @@ -62,11 +51,19 @@ export default defineComponent({ props: { currentFolder: { - type: Folder, + type: Object as PropType<Folder>, required: true, }, }, + setup() { + const { currentView } = useNavigation() + + return { + currentView, + } + }, + data() { return { dragover: false, @@ -88,22 +85,33 @@ export default defineComponent({ if (this.isQuotaExceeded) { return this.t('files', 'Your have used your space quota and cannot upload files anymore') } else if (!this.canUpload) { - return this.t('files', 'You don’t have permission to upload or create files here') + return this.t('files', 'You do not have permission to upload or create files here.') } return null }, + + /** + * Debounced function to reset the drag over state + * Required as Firefox has a bug where no dragleave is emitted: + * https://bugzilla.mozilla.org/show_bug.cgi?id=656164 + */ + resetDragOver() { + return debounce(() => { + this.dragover = false + }, 3000) + }, }, mounted() { // Add events on parent to cover both the table and DragAndDrop notice - const mainContent = window.document.querySelector('main.app-content') as HTMLElement + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement mainContent.addEventListener('dragover', this.onDragOver) mainContent.addEventListener('dragleave', this.onDragLeave) mainContent.addEventListener('drop', this.onContentDrop) }, beforeDestroy() { - const mainContent = window.document.querySelector('main.app-content') as HTMLElement + const mainContent = window.document.getElementById('app-content-vue') as HTMLElement mainContent.removeEventListener('dragover', this.onDragOver) mainContent.removeEventListener('dragleave', this.onDragLeave) mainContent.removeEventListener('drop', this.onContentDrop) @@ -118,6 +126,7 @@ export default defineComponent({ if (isForeignFile) { // Only handle uploading of outside files (not Nextcloud files) this.dragover = true + this.resetDragOver() } }, @@ -126,12 +135,13 @@ export default defineComponent({ // only when we're leaving the current element // Avoid flickering const currentTarget = event.currentTarget as HTMLElement - if (currentTarget?.contains(event.relatedTarget as HTMLElement)) { + if (currentTarget?.contains((event.relatedTarget ?? event.target) as HTMLElement)) { return } if (this.dragover) { this.dragover = false + this.resetDragOver.clear() } }, @@ -140,13 +150,13 @@ export default defineComponent({ event.preventDefault() if (this.dragover) { this.dragover = false + this.resetDragOver.clear() } }, - onDrop(event: DragEvent) { - logger.debug('Dropped on DragAndDropNotice', { event, error: this.cantUploadLabel }) - - if (!this.canUpload || this.isQuotaExceeded) { + async onDrop(event: DragEvent) { + // cantUploadLabel is null if we can upload + if (this.cantUploadLabel) { showError(this.cantUploadLabel) return } @@ -158,30 +168,61 @@ export default defineComponent({ event.preventDefault() event.stopPropagation() - if (event.dataTransfer && event.dataTransfer.items.length > 0) { - // Start upload - logger.debug(`Uploading files to ${this.currentFolder.path}`) - // Process finished uploads - handleDrop(event.dataTransfer).then((uploads) => { - logger.debug('Upload terminated', { uploads }) - showSuccess(t('files', 'Upload successful')) - - // Scroll to last upload in current directory if terminated - const lastUpload = uploads.findLast((upload) => !upload.file.webkitRelativePath.includes('/') && upload.response?.headers?.['oc-fileid']) - if (lastUpload !== undefined) { - this.$router.push({ - ...this.$route, - params: { - view: this.$route.params?.view ?? 'files', - // Remove instanceid from header response - fileid: parseInt(lastUpload.response!.headers['oc-fileid']), - }, - }) - } - }) + // Caching the selection + const items: DataTransferItem[] = [...event.dataTransfer?.items || []] + + // 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.currentFolder.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 (event.button) { + return + } + + logger.debug('Dropped', { event, folder, fileTree }) + + // Check whether we're uploading files + const uploads = await onDropExternalFiles(fileTree, folder, contents.contents) + + // Scroll to last successful upload in current directory if terminated + const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED + && !upload.file.webkitRelativePath.includes('/') + && upload.response?.headers?.['oc-fileid'] + // Only use the last ID if it's in the current folder + && upload.source.replace(folder.source, '').split('/').length === 2) + + if (lastUpload !== undefined) { + logger.debug('Scrolling to last upload in current folder', { lastUpload }) + const location: RawLocation = { + path: this.$route.path, + // Keep params but change file id + params: { + ...this.$route.params, + fileid: String(lastUpload.response!.headers['oc-fileid']), + }, + query: { + ...this.$route.query, + }, + } + // Remove open file from query + delete location.query.openfile + this.$router.push(location) } + this.dragover = false + this.resetDragOver.clear() }, + t, }, }) @@ -194,7 +235,7 @@ export default defineComponent({ justify-content: center; width: 100%; // Breadcrumbs height + row thead height - min-height: calc(58px + 55px); + min-height: calc(58px + 44px); margin: 0; user-select: none; color: var(--color-text-maxcontrast); @@ -202,7 +243,7 @@ export default defineComponent({ border-color: black; h3 { - margin-left: 16px; + margin-inline-start: 16px; color: inherit; } diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue index 1284eed2566..72fd98d43fb 100644 --- a/apps/files/src/components/DragAndDropPreview.vue +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="files-list-drag-image"> <span class="files-list-drag-image__icon"> @@ -79,7 +62,7 @@ export default Vue.extend({ summary(): string { if (this.isSingleNode) { const node = this.nodes[0] - return node.attributes?.displayName || node.basename + return node.attributes?.displayname || node.basename } return getSummaryFor(this.nodes) @@ -109,34 +92,34 @@ export default Vue.extend({ </script> <style lang="scss"> -$size: 32px; +$size: 28px; $stack-shift: 6px; .files-list-drag-image { position: absolute; top: -9999px; - left: -9999px; + inset-inline-start: -9999px; display: flex; overflow: hidden; align-items: center; - height: 44px; - padding: 6px 12px; + height: $size + $stack-shift; + padding: $stack-shift $stack-shift * 2; background: var(--color-main-background); &__icon, - .files-list__row-icon { + .files-list__row-icon-preview-container { display: flex; overflow: hidden; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: $size - $stack-shift; + height: $size - $stack-shift;; border-radius: var(--border-radius); } &__icon { overflow: visible; - margin-right: 12px; + margin-inline-end: $stack-shift * 2; img { max-width: 100%; @@ -155,13 +138,15 @@ $stack-shift: 6px; display: flex; // Stack effect if more than one element - .files-list__row-icon + .files-list__row-icon { + // Max 3 elements + > .files-list__row-icon-preview-container + .files-list__row-icon-preview-container { margin-top: $stack-shift; - margin-left: $stack-shift - $size; - & + .files-list__row-icon { + margin-inline-start: $stack-shift * 2 - $size; + & + .files-list__row-icon-preview-container { margin-top: $stack-shift * 2; } } + // If we have manually clone the preview, // let's hide any fallback icons &:not(:empty) + * { diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 8b4c7b71ef9..d66c3fa0ed7 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -1,27 +1,14 @@ <!-- - - @copyright Copyright (c) 2023 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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <tr :class="{'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" + <tr :class="{ + 'files-list__row--dragover': dragover, + 'files-list__row--loading': isLoading, + 'files-list__row--active': isActive, + }" data-cy-files-list-row :data-cy-files-list-row-fileid="fileid" :data-cy-files-list-row-name="source.basename" @@ -29,7 +16,7 @@ class="files-list__row" v-on="rowListeners"> <!-- Failed indicator --> - <span v-if="source.attributes.failed" class="files-list__row--failed" /> + <span v-if="isFailedSource" class="files-list__row--failed" /> <!-- Checkbox --> <FileEntryCheckbox :fileid="fileid" @@ -43,26 +30,34 @@ <FileEntryPreview ref="preview" :source="source" :dragover="dragover" + @auxclick.native="execDefaultAction" @click.native="execDefaultAction" /> <FileEntryName ref="name" - :display-name="displayName" + :basename="basename" :extension="extension" - :files-list-width="filesListWidth" :nodes="nodes" :source="source" - @click="execDefaultAction" /> + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> </td> <!-- Actions --> <FileEntryActions v-show="!isRenamingSmallScreen" ref="actions" :class="`files-list__row-actions-${uniqueId}`" - :files-list-width="filesListWidth" - :loading.sync="loading" :opened.sync="openedMenu" :source="source" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + :title="mime" + class="files-list__row-mime" + data-cy-files-list-row-mime + @click="openDetailsIfAvailable"> + <span>{{ mime }}</span> + </td> + <!-- Size --> <td v-if="!compact && isSizeAvailable" :style="sizeOpacity" @@ -78,13 +73,16 @@ class="files-list__row-mtime" data-cy-files-list-row-mtime @click="openDetailsIfAvailable"> - <NcDateTime :timestamp="source.mtime" :ignore-seconds="true" /> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> + <span v-else>{{ t('files', 'Unknown date') }}</span> </td> <!-- View columns --> <td v-for="column in columns" :key="column.id" - :class="`files-list__row-${currentView?.id}-${column.id}`" + :class="`files-list__row-${currentView.id}-${column.id}`" class="files-list__row-column-custom" :data-cy-files-list-row-column-custom="column.id" @click="openDetailsIfAvailable"> @@ -96,37 +94,27 @@ </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 { getUploader } from '@nextcloud/upload' -import { showError } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { vOnClickOutside } from '@vueuse/components' -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 { FileType, formatFileSize } from '@nextcloud/files' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' + +import { useNavigation } from '../composables/useNavigation.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useRouteParameters } from '../composables/useRouteParameters.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 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 FileEntryMixin from './FileEntryMixin.ts' 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,8 +128,12 @@ export default defineComponent({ NcDateTime, }, + mixins: [ + FileEntryMixin, + ], + props: { - isMtimeAvailable: { + isMimeAvailable: { type: Boolean, default: false, }, @@ -149,22 +141,6 @@ 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() { @@ -173,19 +149,25 @@ export default defineComponent({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + const filesListWidth = useFileListWidth() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, - } - }, - data() { - return { - loading: '', - dragover: false, + currentDir, + currentFileId, + currentView, + filesListWidth, } }, @@ -210,348 +192,85 @@ 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) { return [] } - 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?.toString?.() - }, - uniqueId() { - return hashCode(this.source.source) - }, - isLoading() { - return this.source.status === NodeStatus.LOADING + return this.currentView.columns || [] }, - extension() { - if (this.source.attributes?.displayName) { - return extname(this.source.attributes.displayName) + mime() { + if (this.source.type === FileType.Folder) { + return this.t('files', 'Folder') } - 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') + if (!this.source.mime || this.source.mime === 'application/octet-stream') { + return t('files', 'Unknown file type') } - return formatFileSize(size, true) - }, - sizeOpacity() { - const maxOpacitySize = 10 * 1024 * 1024 - const size = parseInt(this.source.size, 10) || 0 - if (!size || size < 0) { - return {} + if (window.OC?.MimeTypeList?.names?.[this.source.mime]) { + return window.OC.MimeTypeList.names[this.source.mime] } - const ratio = Math.round(Math.min(100, 100 * Math.pow((this.source.size / maxOpacitySize), 2))) - return { - color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, - } - }, - mtimeOpacity() { - const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days - - const mtime = this.source.mtime?.getTime?.() - if (!mtime) { - return {} + const baseType = this.source.mime.split('/')[0] + const ext = this.source?.extension?.toUpperCase().replace(/^\./, '') || '' + if (baseType === 'image') { + return t('files', '{ext} image', { ext }) } - - // 1 = today, 0 = 31 days ago - const ratio = Math.round(Math.min(100, 100 * (maxOpacityTime - (Date.now() - mtime)) / maxOpacityTime)) - if (ratio < 0) { - return {} + if (baseType === 'video') { + return t('files', '{ext} video', { ext }) } - return { - color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, + if (baseType === 'audio') { + return t('files', '{ext} audio', { ext }) } - }, - mtimeTitle() { - if (this.source.mtime) { - return moment(this.source.mtime).format('LLL') + if (baseType === 'text') { + return t('files', '{ext} text', { ext }) } - return '' - }, - - draggingFiles() { - return this.draggingStore.dragging - }, - selectedFiles() { - return this.selectionStore.selected - }, - isSelected() { - return this.selectedFiles.includes(this.fileid) - }, - isRenaming() { - return this.renamingStore.renamingNode === this.source + return this.source.mime }, - isRenamingSmallScreen() { - return this.isRenaming && this.filesListWidth < 512 - }, - - isActive() { - return this.fileid === 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) + size() { + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return this.t('files', 'Pending') } - return canDrag(this.source) + return formatFileSize(size, true) }, - canDrop() { - if (this.source.type !== FileType.Folder) { - return false - } + sizeOpacity() { + const maxOpacitySize = 10 * 1024 * 1024 - // If the current folder is also being dragged, we can't drop it on itself - if (this.draggingFiles.includes(this.fileid)) { - return false + const size = this.source.size + if (size === undefined || isNaN(size) || size < 0) { + return {} } - return (this.source.permissions & Permission.CREATE) !== 0 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId - }, - 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 : null - }, - }, - }, - - watch: { - /** - * When the source changes, reset the preview - * and fetch the new one. - */ - source() { - this.resetState() + const ratio = Math.round(Math.min(100, 100 * Math.pow((size / maxOpacitySize), 2))) + return { + color: `color-mix(in srgb, var(--color-main-text) ${ratio}%, var(--color-text-maxcontrast))`, + } }, }, - beforeDestroy() { - this.resetState() + created() { + useHotKey('Enter', this.triggerDefaultAction, { + stop: true, + prevent: true, + }) }, 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 - - // 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) { - 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 }) + formatFileSize, - // 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}`) + triggerDefaultAction() { + // Don't react to the event if the file row is not active + if (!this.isActive) { 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() - } + this.defaultFileAction?.exec(this.source, this.currentView, this.currentDir) }, - - t, - formatFileSize, }, }) </script> diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue index 4316183e3ff..e22b30f4378 100644 --- a/apps/files/src/components/FileEntry/CollectivesIcon.vue +++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> <template> <span :aria-hidden="!title" :aria-label="title" diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue index 2d3373d7b42..c66cb8fbd7f 100644 --- a/apps/files/src/components/FileEntry/FavoriteIcon.vue +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> - - - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcIconSvgWrapper class="favorite-marker-icon" :name="t('files', 'Favorite')" :svg="StarSvg" /> </template> @@ -28,7 +11,7 @@ import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' import StarSvg from '@mdi/svg/svg/star.svg?raw' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' /** * A favorite icon to be used for overlaying favorite entries like the file preview / icon @@ -53,7 +36,7 @@ export default defineComponent({ }, async mounted() { await this.$nextTick() - // MDI default viewbox is "0 0 24 24" but we add a stroke of 10px so we must adjust it + // MDI default viewBox is "0 0 24 24" but we add a stroke of 10px so we must adjust it const el = this.$el.querySelector('svg') el?.setAttribute?.('viewBox', '-4 -4 30 30') }, @@ -65,7 +48,7 @@ export default defineComponent({ <style lang="scss" scoped> .favorite-marker-icon { - color: #a08b00; + color: var(--color-favorite); // Override NcIconSvgWrapper defaults (clickable area) min-width: unset !important; min-height: unset !important; @@ -73,8 +56,8 @@ export default defineComponent({ :deep() { svg { // We added a stroke for a11y so we must increase the size to include the stroke - width: 26px !important; - height: 26px !important; + width: 20px !important; + height: 20px !important; // Override NcIconSvgWrapper defaults of 20px max-width: unset !important; diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue index 1e453fec706..5c537d878fe 100644 --- a/apps/files/src/components/FileEntry/FileEntryActions.vue +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <td class="files-list__row-actions" data-cy-files-list-row-actions> @@ -35,40 +18,76 @@ <NcActions ref="actionsMenu" :boundaries-element="getBoundariesElement" :container="getBoundariesElement" - :disabled="isLoading || loading !== ''" :force-name="true" type="tertiary" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" - :open.sync="openedMenu" - @close="openedSubmenu = null"> - <!-- Default actions list--> - <NcActionButton v-for="action in enabledMenuActions" + :open="openedMenu" + @close="onMenuClose" + @closed="onMenuClosed"> + <!-- Non-destructive actions list --> + <!-- Please keep this block in sync with the destructive actions block below --> + <NcActionButton v-for="action, index in renderedNonDestructiveActions" :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" :class="{ [`files-list__row-action-${action.id}`]: true, - [`files-list__row-action--menu`]: isMenu(action.id) + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), }" - :close-after-click="!isMenu(action.id)" + :close-after-click="!isValidMenu(action)" :data-cy-files-list-row-action="action.id" - :is-menu="isMenu(action.id)" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" :title="action.title?.([source], currentView)" @click="onActionClick(action)"> <template #icon> - <NcLoadingIcon v-if="loading === action.id" :size="18" /> - <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> </template> - {{ mountType === 'shared' && action.id === 'sharing-status' ? '' : actionDisplayName(action) }} + {{ actionDisplayName(action) }} </NcActionButton> + <!-- Destructive actions list --> + <template v-if="renderedDestructiveActions.length > 0"> + <NcActionSeparator /> + <NcActionButton v-for="action, index in renderedDestructiveActions" + :key="action.id" + :ref="`action-${action.id}`" + class="files-list__row-action" + :class="{ + [`files-list__row-action-${action.id}`]: true, + 'files-list__row-action--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + 'files-list__row-action--destructive': true, + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-row-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.title?.([source], currentView)" + :title="action.title?.([source], currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else + class="files-list__row-action-icon" + :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + <!-- Submenu actions list--> <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> <!-- Back to top-level button --> - <NcActionButton class="files-list__row-action-back" @click="openedSubmenu = null"> + <NcActionButton class="files-list__row-action-back" data-cy-files-list-row-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> <template #icon> <ArrowLeftIcon /> </template> - {{ actionDisplayName(openedSubmenu) }} + {{ t('files', 'Back') }} </NcActionButton> <NcActionSeparator /> @@ -77,12 +96,13 @@ :key="action.id" :class="`files-list__row-action-${action.id}`" class="files-list__row-action--submenu" - :close-after-click="false /* never close submenu, just go back */" + close-after-click :data-cy-files-list-row-action="action.id" + :aria-label="action.title?.([source], currentView)" :title="action.title?.([source], currentView)" @click="onActionClick(action)"> <template #icon> - <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcLoadingIcon v-if="isLoadingAction(action)" /> <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> </template> {{ actionDisplayName(action) }} @@ -93,31 +113,35 @@ </template> <script lang="ts"> -import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files' -import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import Vue, { PropType } from 'vue' +import type { PropType } from 'vue' +import type { FileAction, Node } from '@nextcloud/files' -import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' -import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import { DefaultType, NodeStatus } from '@nextcloud/files' +import { defineComponent, inject } from 'vue' +import { t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' import CustomElementRender from '../CustomElementRender.vue' -import logger from '../../logger.js' - -// The registered actions list -const actions = getFileActions() - -export default Vue.extend({ +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import { executeAction } from '../../utils/actionUtils.ts' +import { useActiveStore } from '../../store/active.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import actionsMixins from '../../mixins/actionsMixin.ts' +import logger from '../../logger.ts' + +export default defineComponent({ name: 'FileEntryActions', components: { ArrowLeftIcon, - ChevronRightIcon, CustomElementRender, NcActionButton, NcActions, @@ -126,15 +150,9 @@ export default Vue.extend({ NcLoadingIcon, }, + mixins: [actionsMixins], + props: { - filesListWidth: { - type: Number, - required: true, - }, - loading: { - type: String, - required: true, - }, opened: { type: Boolean, default: false, @@ -149,41 +167,46 @@ export default Vue.extend({ }, }, - data() { + setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory: currentDir } = useRouteParameters() + + const activeStore = useActiveStore() + const filesListWidth = useFileListWidth() + const enabledFileActions = inject<FileAction[]>('enabledFileActions', []) return { - openedSubmenu: null as FileAction | null, + activeStore, + currentDir, + currentView, + enabledFileActions, + filesListWidth, + t, } }, computed: { - currentDir() { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1') - }, - currentView(): View { - return this.$navigation.active as View + isActive() { + return this.activeStore?.activeNode?.source === this.source.source }, + isLoading() { return this.source.status === NodeStatus.LOADING }, - // Sorted actions that are enabled for this node - enabledActions() { - if (this.source.attributes.failed) { - return [] - } - - return actions - .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - }, - // Enabled action that are displayed inline enabledInlineActions() { if (this.filesListWidth < 768 || this.gridMode) { return [] } - return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) + return this.enabledFileActions.filter(action => { + try { + return action?.inline?.(this.source, this.currentView) + } catch (error) { + logger.error('Error while checking if action is inline', { action, error }) + return false + } + }) }, // Enabled action that are displayed inline with a custom render function @@ -191,12 +214,7 @@ export default Vue.extend({ if (this.gridMode) { return [] } - return this.enabledActions.filter(action => typeof action.renderInline === 'function') - }, - - // Default actions - enabledDefaultActions() { - return this.enabledActions.filter(action => !!action?.default) + return this.enabledFileActions.filter(action => typeof action.renderInline === 'function') }, // Actions shown in the menu @@ -211,7 +229,7 @@ export default Vue.extend({ // Showing inline first for the NcActions inline prop ...this.enabledInlineActions, // Then the rest - ...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'), + ...this.enabledFileActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'), ].filter((value, index, self) => { // Then we filter duplicates to prevent inline actions to be shown twice return index === self.findIndex(action => action.id === value.id) @@ -224,16 +242,12 @@ export default Vue.extend({ return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) }, - enabledSubmenuActions() { - return this.enabledActions - .filter(action => action.parent) - .reduce((arr, action) => { - if (!arr[action.parent]) { - arr[action.parent] = [] - } - arr[action.parent].push(action) - return arr - }, {} as Record<string, FileAction>) + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) }, openedMenu: { @@ -253,76 +267,91 @@ export default Vue.extend({ getBoundariesElement() { return document.querySelector('.app-content > .files-list') }, + }, - mountType() { - return this.source._attributes['mount-type'] + watch: { + // Close any submenu when the menu state changes + openedMenu() { + this.openedSubmenu = null }, }, + created() { + useHotKey('Escape', this.onKeyDown, { + stop: true, + prevent: true, + }) + + useHotKey('a', this.onKeyDown, { + stop: true, + prevent: true, + }) + }, + methods: { actionDisplayName(action: FileAction) { - if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') { - // if an inline action is rendered in the menu for - // lack of space we use the title first if defined - const title = action.title([this.source], this.currentView) - if (title) return title + try { + if ((this.gridMode || (this.filesListWidth < 768 && action.inline)) && typeof action.title === 'function') { + // if an inline action is rendered in the menu for + // lack of space we use the title first if defined + const title = action.title([this.source], this.currentView) + if (title) return title + } + return action.displayName([this.source], this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + // Not ideal, but better than nothing + return action.id } - return action.displayName([this.source], this.currentView) }, - async onActionClick(action, isSubmenu = false) { + isLoadingAction(action: FileAction) { + if (!this.isActive) { + return false + } + return this.activeStore?.activeAction?.id === action.id + }, + + async onActionClick(action) { // If the action is a submenu, we open it if (this.enabledSubmenuActions[action.id]) { this.openedSubmenu = action return } - const displayName = action.displayName([this.source], this.currentView) - try { - // Set the loading marker - this.$emit('update:loading', action.id) - Vue.set(this.source, 'status', NodeStatus.LOADING) + // Make sure we set the node as active + this.activeStore.activeNode = this.source - const success = await action.exec(this.source, this.currentView, this.currentDir) + // Execute the action + await executeAction(action) + }, - // If the action returns null, we stay silent - if (success === null || success === undefined) { - return - } + onKeyDown(event: KeyboardEvent) { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } - if (success) { - showSuccess(t('files', '"{displayName}" action executed successfully', { displayName })) - return - } - showError(t('files', '"{displayName}" action failed', { displayName })) - } catch (e) { - logger.error('Error while executing action', { action, e }) - showError(t('files', '"{displayName}" action failed', { displayName })) - } finally { - // Reset the loading marker - this.$emit('update:loading', '') - Vue.set(this.source, 'status', undefined) - - // If that was a submenu, we just go back after the action - if (isSubmenu) { - this.openedSubmenu = null - } + // ESC close the action menu if opened + if (event.key === 'Escape' && this.openedMenu) { + this.openedMenu = false } - }, - execDefaultAction(event) { - if (this.enabledDefaultActions.length > 0) { - event.preventDefault() - event.stopPropagation() - // Execute the first default action if any - this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir) + + // a open the action menu + if (event.key === 'a' && !this.openedMenu) { + this.openedMenu = true } }, - isMenu(id: string) { - return this.enabledSubmenuActions[id]?.length > 0 + onMenuClose() { + // We reset the submenu state when the menu is closing + this.openedSubmenu = null }, - t, + onMenuClosed() { + // We reset the actions menu state when the menu is finally closed + this.openedMenu = false + }, }, }) </script> @@ -330,12 +359,13 @@ 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 { +main.app-content[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 &[data-popper-placement="top"] { - transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh), 0px) !important; + // 34px added to align with the top of the cursor + transform: translate3d(var(--mouse-pos-x), calc(var(--mouse-pos-y) - 50vh + 34px), 0px) !important; } // Hide arrow if floating .v-popper__arrow-container { @@ -344,13 +374,26 @@ export default Vue.extend({ } </style> -<style lang="scss" scoped> -:deep(.button-vue--icon-and-text, .files-list__row-action-sharing-status) { - .button-vue__text { - color: var(--color-primary-element); +<style scoped lang="scss"> +.files-list__row-action { + --max-icon-size: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline)); + + // inline icons can have clickable area size so they still fit into the row + &.files-list__row-action--inline { + --max-icon-size: var(--default-clickable-area); } - .button-vue__icon { - color: var(--color-primary-element); + + // Some icons exceed the default size so we need to enforce a max width and height + .files-list__row-action-icon :deep(svg) { + max-height: var(--max-icon-size) !important; + max-width: var(--max-icon-size) !important; + } + + &.files-list__row-action--destructive { + ::deep(button) { + color: var(--color-error) !important; + } } } + </style> diff --git a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue index bb851ed1e0e..5b80a971118 100644 --- a/apps/files/src/components/FileEntry/FileEntryCheckbox.vue +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -1,48 +1,38 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <td class="files-list__row-checkbox" @keyup.esc.exact="resetSelection"> - <NcLoadingIcon v-if="isLoading" /> + <NcLoadingIcon v-if="isLoading" :name="loadingLabel" /> <NcCheckboxRadioSwitch v-else :aria-label="ariaLabel" :checked="isSelected" + data-cy-files-list-row-checkbox @update:checked="onSelectionChange" /> </td> </template> <script lang="ts"> -import { Node, FileType } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../../types.ts' + +import { FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import Vue, { PropType } from 'vue' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import { useActiveStore } from '../../store/active.ts' import { useKeyboardStore } from '../../store/keyboard.ts' import { useSelectionStore } from '../../store/selection.ts' -import logger from '../../logger.js' +import logger from '../../logger.ts' -export default Vue.extend({ +export default defineComponent({ name: 'FileEntryCheckbox', components: { @@ -52,7 +42,7 @@ export default Vue.extend({ props: { fileid: { - type: String, + type: Number, required: true, }, isLoading: { @@ -72,21 +62,29 @@ export default Vue.extend({ setup() { const selectionStore = useSelectionStore() const keyboardStore = useKeyboardStore() + const activeStore = useActiveStore() + return { + activeStore, keyboardStore, selectionStore, + t, } }, computed: { + isActive() { + return this.activeStore.activeNode?.source === this.source.source + }, + selectedFiles() { return this.selectionStore.selected }, isSelected() { - return this.selectedFiles.includes(this.fileid) + return this.selectedFiles.includes(this.source.source) }, index() { - return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid)) + return this.nodes.findIndex((node: Node) => node.source === this.source.source) }, isFile() { return this.source.type === FileType.File @@ -96,6 +94,28 @@ export default Vue.extend({ ? t('files', 'Toggle selection for file "{displayName}"', { displayName: this.source.basename }) : t('files', 'Toggle selection for folder "{displayName}"', { displayName: this.source.basename }) }, + loadingLabel() { + return this.isFile + ? t('files', 'File is loading') + : t('files', 'Folder is loading') + }, + }, + + created() { + // ctrl+space toggle selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + }) + + // ctrl+shift+space toggle range selection + useHotKey(' ', this.onToggleSelect, { + stop: true, + prevent: true, + ctrl: true, + shift: true, + }) }, methods: { @@ -105,19 +125,20 @@ export default Vue.extend({ // Get the last selected and select all files in between if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) { - const isAlreadySelected = this.selectedFiles.includes(this.fileid) + const isAlreadySelected = this.selectedFiles.includes(this.source.source) const start = Math.min(newSelectedIndex, lastSelectedIndex) const end = Math.max(lastSelectedIndex, newSelectedIndex) const lastSelection = this.selectionStore.lastSelection const filesToSelect = this.nodes - .map(file => file.fileid?.toString?.()) + .map(file => file.source) .slice(start, end + 1) + .filter(Boolean) as FileSource[] // If already selected, update the new selection _without_ the current file const selection = [...lastSelection, ...filesToSelect] - .filter(fileid => !isAlreadySelected || fileid !== this.fileid) + .filter(source => !isAlreadySelected || source !== this.source.source) logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected }) // Keep previous lastSelectedIndex to be use for further shift selections @@ -126,8 +147,8 @@ export default Vue.extend({ } const selection = selected - ? [...this.selectedFiles, this.fileid] - : this.selectedFiles.filter(fileid => fileid !== this.fileid) + ? [...this.selectedFiles, this.source.source] + : this.selectedFiles.filter(source => source !== this.source.source) logger.debug('Updating selection', { selection }) this.selectionStore.set(selection) @@ -138,7 +159,15 @@ export default Vue.extend({ this.selectionStore.reset() }, - t, + onToggleSelect() { + // Don't react if the node is not active + if (!this.isActive) { + return + } + + logger.debug('Toggling selection for file', { source: this.source }) + this.onSelectionChange(!this.isSelected) + }, }, }) </script> diff --git a/apps/files/src/components/FileEntry/FileEntryName.vue b/apps/files/src/components/FileEntry/FileEntryName.vue index 87859de353a..418f9581eb6 100644 --- a/apps/files/src/components/FileEntry/FileEntryName.vue +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -1,28 +1,12 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <!-- Rename input --> <form v-if="isRenaming" - v-on-click-outside="stopRenaming" + ref="renameForm" + v-on-click-outside="onRename" :aria-label="t('files', 'Rename file')" class="files-list__row-rename" @submit.prevent.stop="onRename"> @@ -33,46 +17,44 @@ :required="true" :value.sync="newName" enterkeyhint="done" - @keyup="checkInputValidity" @keyup.esc="stopRenaming" /> </form> <component :is="linkTo.is" v-else ref="basename" - :aria-hidden="isRenaming" class="files-list__row-name-link" data-cy-files-list-row-name-link - v-bind="linkTo.params" - @click="$emit('click', $event)"> - <!-- File name --> - <span class="files-list__row-name-text"> - <!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues--> - <span class="files-list__row-name-" v-text="displayName" /> - <span class="files-list__row-name-ext" v-text="extension" /> + v-bind="linkTo.params"> + <!-- Filename --> + <span class="files-list__row-name-text" dir="auto"> + <!-- Keep the filename stuck to the extension to avoid whitespace rendering issues--> + <span class="files-list__row-name-" v-text="basename" /> + <span v-if="userConfigStore.userConfig.show_files_extensions" class="files-list__row-name-ext" v-text="extension" /> </span> </component> </template> <script lang="ts"> +import type { FileAction, Node } from '@nextcloud/files' import type { PropType } from 'vue' -import { emit } from '@nextcloud/event-bus' -import { FileType, NodeStatus, Permission } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' import { showError, showSuccess } from '@nextcloud/dialogs' +import { FileType, NodeStatus } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import axios from '@nextcloud/axios' -import Vue from 'vue' +import { defineComponent, inject } from 'vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import { getFilenameValidity } from '../../utils/filenameValidity.ts' +import { useFileListWidth } from '../../composables/useFileListWidth.ts' +import { useNavigation } from '../../composables/useNavigation.ts' import { useRenamingStore } from '../../store/renaming.ts' -import logger from '../../logger.js' +import { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' -const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string - -export default Vue.extend({ +export default defineComponent({ name: 'FileEntryName', components: { @@ -80,18 +62,20 @@ export default Vue.extend({ }, props: { - displayName: { + /** + * The filename without extension + */ + basename: { type: String, required: true, }, + /** + * The extension of the filename + */ extension: { type: String, required: true, }, - filesListWidth: { - type: Number, - required: true, - }, nodes: { type: Array as PropType<Node[]>, required: true, @@ -107,9 +91,23 @@ export default Vue.extend({ }, setup() { + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { directory } = useRouteParameters() + const filesListWidth = useFileListWidth() const renamingStore = useRenamingStore() + const userConfigStore = useUserConfigStore() + + const defaultFileAction = inject<FileAction | undefined>('defaultFileAction') + return { + currentView, + defaultFileAction, + directory, + filesListWidth, + renamingStore, + userConfigStore, } }, @@ -121,24 +119,24 @@ export default Vue.extend({ return this.isRenaming && this.filesListWidth < 512 }, newName: { - get() { - return this.renamingStore.newName + get(): string { + return this.renamingStore.newNodeName }, - set(newName) { - this.renamingStore.newName = newName + set(newName: string) { + this.renamingStore.newNodeName = newName }, }, renameLabel() { const matchLabel: Record<FileType, string> = { - [FileType.File]: t('files', 'File name'), + [FileType.File]: t('files', 'Filename'), [FileType.Folder]: t('files', 'Folder name'), } return matchLabel[this.source.type] }, linkTo() { - if (this.source.attributes.failed) { + if (this.source.status === NodeStatus.FAILED) { return { is: 'span', params: { @@ -147,32 +145,20 @@ export default Vue.extend({ } } - const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions - if (enabledDefaultActions?.length > 0) { - const action = enabledDefaultActions[0] - const displayName = action.displayName([this.source], this.currentView) + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) return { - is: 'a', + is: 'button', params: { + 'aria-label': displayName, title: displayName, - role: 'button', - tabindex: '0', - }, - } - } - - if (this.source?.permissions & Permission.READ) { - return { - is: 'a', - params: { - download: this.source.basename, - href: this.source.source, - title: t('files', 'Download file {name}', { name: this.displayName }), tabindex: '0', }, } } + // nothing interactive here, there is no default action + // so if not even the download action works we only can show the list entry return { is: 'span', } @@ -181,7 +167,7 @@ export default Vue.extend({ watch: { /** - * If renaming starts, select the file name + * If renaming starts, select the filename * in the input, without the extension. * @param renaming */ @@ -193,73 +179,51 @@ export default Vue.extend({ } }, }, - }, - methods: { - /** - * Check if the file name is valid and update the - * input validity using browser's native validation. - * @param event the keyup event - */ - checkInputValidity(event?: KeyboardEvent) { - const input = event.target as HTMLInputElement + newName() { + // Check validity of the new name const newName = this.newName.trim?.() || '' - logger.debug('Checking input validity', { newName }) - try { - this.isFileNameValid(newName) - input.setCustomValidity('') - input.title = '' - } catch (e) { - input.setCustomValidity(e.message) - input.title = e.message - } finally { - input.reportValidity() - } - }, - isFileNameValid(name) { - const trimmedName = name.trim() - if (trimmedName === '.' || trimmedName === '..') { - throw new Error(t('files', '"{name}" is an invalid file name.', { name })) - } else if (trimmedName.length === 0) { - throw new Error(t('files', 'File name cannot be empty.')) - } else if (trimmedName.indexOf('/') !== -1) { - throw new Error(t('files', '"/" is not allowed inside a file name.')) - } else if (trimmedName.match(OC.config.blacklist_files_regex)) { - throw new Error(t('files', '"{name}" is not an allowed filetype.', { name })) - } else if (this.checkIfNodeExists(name)) { - throw new Error(t('files', '{newName} already exists.', { newName: name })) + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return } - const toCheck = trimmedName.split('') - toCheck.forEach(char => { - if (forbiddenCharacters.indexOf(char) !== -1) { - throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char })) + let validity = getFilenameValidity(newName) + // Checking if already exists + if (validity === '' && this.checkIfNodeExists(newName)) { + validity = t('files', 'Another entry with the same name already exists.') + } + this.$nextTick(() => { + if (this.isRenaming) { + input.setCustomValidity(validity) + input.reportValidity() } }) - - return true }, - checkIfNodeExists(name) { + }, + + methods: { + checkIfNodeExists(name: string) { return this.nodes.find(node => node.basename === name && node !== this.source) }, startRenaming() { this.$nextTick(() => { // Using split to get the true string length - const extLength = (this.source.extension || '').split('').length - const length = this.source.basename.split('').length - extLength - const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') if (!input) { logger.error('Could not find the rename input') return } - input.setSelectionRange(0, length) input.focus() + const length = this.source.basename.length - (this.source.extension ?? '').length + input.setSelectionRange(0, length) // Trigger a keyup event to update the input validity input.dispatchEvent(new Event('keyup')) }) }, + stopRenaming() { if (!this.isRenaming) { return @@ -271,72 +235,37 @@ export default Vue.extend({ // Rename and move the file async onRename() { - const oldName = this.source.basename - const oldEncodedSource = this.source.encodedSource const newName = this.newName.trim?.() || '' - if (newName === '') { - showError(t('files', 'Name cannot be empty')) + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) return } - if (oldName === newName) { + const oldName = this.source.basename + if (newName === oldName) { this.stopRenaming() return } - // Checking if already exists - if (this.checkIfNodeExists(newName)) { - showError(t('files', 'Another entry with the same name already exists')) - return - } - - // Set loading state - this.loading = 'renaming' - Vue.set(this.source, 'status', NodeStatus.LOADING) - - // Update node - this.source.rename(newName) - - logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource }) try { - await axios({ - method: 'MOVE', - url: oldEncodedSource, - headers: { - Destination: this.source.encodedSource, - Overwrite: 'F', - }, - }) - - // Success 🎉 - emit('files:node:updated', this.source) - emit('files:node:renamed', this.source) - showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName })) - - // Reset the renaming store - this.stopRenaming() - this.$nextTick(() => { - this.$refs.basename.focus() - }) - } catch (error) { - logger.error('Error while renaming file', { error }) - this.source.rename(oldName) - this.$refs.renameInput.focus() - - // TODO: 409 means current folder does not exist, redirect ? - if (error?.response?.status === 404) { - showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName })) - return - } else if (error?.response?.status === 412) { - showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir })) - return + const status = await this.renamingStore.rename() + if (status) { + showSuccess( + t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName: this.source.basename }), + ) + this.$nextTick(() => { + const nameContainer = this.$refs.basename as HTMLElement | undefined + nameContainer?.focus() + }) + } else { + // Was cancelled - meaning the renaming state is just reset } - - // Unknown error - showError(t('files', 'Could not rename "{oldName}"', { oldName })) - } finally { - this.loading = false - Vue.set(this.source, 'status', undefined) + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() } }, @@ -344,3 +273,16 @@ export default Vue.extend({ }, }) </script> + +<style scoped lang="scss"> +button.files-list__row-name-link { + background-color: unset; + border: none; + font-weight: normal; + + &:active { + // No active styles - handled by the row entry + background-color: unset !important; + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue index 7f8926e2301..3d0fffe7584 100644 --- a/apps/files/src/components/FileEntry/FileEntryPreview.vue +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <span class="files-list__row-icon"> <template v-if="source.type === 'folder'"> @@ -31,16 +14,23 @@ </template> </template> - <!-- Decorative image, should not be aria documented --> - <img v-else-if="previewUrl && backgroundFailed !== true" - ref="previewImg" - alt="" - class="files-list__row-icon-preview" - :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" - loading="lazy" - :src="previewUrl" - @error="backgroundFailed = true" - @load="backgroundFailed = false"> + <!-- Decorative images, should not be aria documented --> + <span v-else-if="previewUrl" class="files-list__row-icon-preview-container"> + <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)" + ref="canvas" + class="files-list__row-icon-blurhash" + aria-hidden="true" /> + <img v-if="backgroundFailed !== true" + :key="source.fileid" + ref="previewImg" + alt="" + class="files-list__row-icon-preview" + :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}" + loading="lazy" + :src="previewUrl" + @error="onBackgroundError" + @load="onBackgroundLoad"> + </span> <FileIcon v-else v-once /> @@ -56,13 +46,16 @@ </template> <script lang="ts"> +import type { PropType } from 'vue' import type { UserConfig } from '../../types.ts' -import { File, Folder, Node, FileType } from '@nextcloud/files' -import { generateUrl } from '@nextcloud/router' +import { Node, FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' -import { Type as ShareType } from '@nextcloud/sharing' -import Vue, { PropType } from 'vue' +import { generateUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' +import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { decode } from 'blurhash' +import { defineComponent } from 'vue' import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue' import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue' @@ -71,16 +64,18 @@ import FolderIcon from 'vue-material-design-icons/Folder.vue' import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue' import KeyIcon from 'vue-material-design-icons/Key.vue' import LinkIcon from 'vue-material-design-icons/Link.vue' -import NetworkIcon from 'vue-material-design-icons/Network.vue' +import NetworkIcon from 'vue-material-design-icons/NetworkOutline.vue' import TagIcon from 'vue-material-design-icons/Tag.vue' import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue' -import { useUserConfigStore } from '../../store/userconfig.ts' import CollectivesIcon from './CollectivesIcon.vue' import FavoriteIcon from './FavoriteIcon.vue' + import { isLivePhoto } from '../../services/LivePhotos' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' -export default Vue.extend({ +export default defineComponent({ name: 'FileEntryPreview', components: { @@ -114,21 +109,25 @@ export default Vue.extend({ setup() { const userConfigStore = useUserConfigStore() + const isPublic = isPublicShare() + const publicSharingToken = getSharingToken() + return { userConfigStore, + + isPublic, + publicSharingToken, } }, data() { return { backgroundFailed: undefined as boolean | undefined, + backgroundLoaded: false, } }, computed: { - fileid() { - return this.source?.fileid?.toString?.() - }, isFavorite(): boolean { return this.source.attributes.favorite === 1 }, @@ -149,11 +148,28 @@ export default Vue.extend({ return null } + if (this.source.attributes['has-preview'] !== true + && this.source.mime !== undefined + && this.source.mime !== 'application/octet-stream' + ) { + const previewUrl = generateUrl('/core/mimeicon?mime={mime}', { + mime: this.source.mime, + }) + const url = new URL(window.location.origin + previewUrl) + return url.href + } + try { const previewUrl = this.source.attributes.previewUrl - || generateUrl('/core/preview?fileId={fileid}', { - fileid: this.fileid, - }) + || (this.isPublic + ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', { + token: this.publicSharingToken, + file: this.source.path, + }) + : generateUrl('/core/preview?fileId={fileid}', { + fileid: String(this.source.fileid), + }) + ) const url = new URL(window.location.origin + previewUrl) // Request tiny previews @@ -161,6 +177,10 @@ export default Vue.extend({ url.searchParams.set('y', this.gridMode ? '128' : '32') url.searchParams.set('mimeFallback', 'true') + // Etag to force refresh preview on change + const etag = this.source?.attributes?.etag || '' + url.searchParams.set('v', etag.slice(0, 6)) + // Handle cropping url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') return url.href @@ -194,7 +214,7 @@ export default Vue.extend({ // Link and mail shared folders const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[] - if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) { + if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) { return LinkIcon } @@ -211,19 +231,67 @@ export default Vue.extend({ return AccountGroupIcon case 'collective': return CollectivesIcon + case 'shared': + return AccountPlusIcon } return null }, + + hasBlurhash() { + return this.source.attributes['metadata-blurhash'] !== undefined + }, + }, + + mounted() { + if (this.hasBlurhash && this.$refs.canvas) { + this.drawBlurhash() + } }, methods: { + // Called from FileEntry reset() { - if (this.backgroundFailed === true && this.$refs.previewImg) { - this.$refs.previewImg.src = '' - } - // Reset background state + // Reset background state to cancel any ongoing requests this.backgroundFailed = undefined + this.backgroundLoaded = false + const previewImg = this.$refs.previewImg as HTMLImageElement | undefined + if (previewImg) { + previewImg.src = '' + } + }, + + onBackgroundLoad() { + this.backgroundFailed = false + this.backgroundLoaded = true + }, + + onBackgroundError(event) { + // Do not fail if we just reset the background + if (event.target?.src === '') { + return + } + this.backgroundFailed = true + this.backgroundLoaded = false + }, + + drawBlurhash() { + const canvas = this.$refs.canvas as HTMLCanvasElement + + const width = canvas.width + const height = canvas.height + + const pixels = decode(this.source.attributes['metadata-blurhash'], width, height) + + const ctx = canvas.getContext('2d') + if (ctx === null) { + logger.error('Cannot create context for blurhash canvas') + return + } + + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) }, t, diff --git a/apps/files/src/components/FileEntryGrid.vue b/apps/files/src/components/FileEntryGrid.vue index 99fd45813ed..1bd0572f53b 100644 --- a/apps/files/src/components/FileEntryGrid.vue +++ b/apps/files/src/components/FileEntryGrid.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2023 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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <tr :class="{'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}" @@ -34,7 +17,7 @@ @dragend="onDragEnd" @drop="onDrop"> <!-- Failed indicator --> - <span v-if="source.attributes.failed" class="files-list__row--failed" /> + <span v-if="isFailedSource" class="files-list__row--failed" /> <!-- Checkbox --> <FileEntryCheckbox :fileid="fileid" @@ -49,60 +32,58 @@ :dragover="dragover" :grid-mode="true" :source="source" + @auxclick.native="execDefaultAction" @click.native="execDefaultAction" /> <FileEntryName ref="name" - :display-name="displayName" + :basename="basename" :extension="extension" - :files-list-width="filesListWidth" :grid-mode="true" :nodes="nodes" :source="source" - @click="execDefaultAction" /> + @auxclick.native="execDefaultAction" + @click.native="execDefaultAction" /> + </td> + + <!-- Mtime --> + <td v-if="!compact && isMtimeAvailable" + :style="mtimeOpacity" + class="files-list__row-mtime" + data-cy-files-list-row-mtime + @click="openDetailsIfAvailable"> + <NcDateTime v-if="mtime" + ignore-seconds + :timestamp="mtime" /> </td> <!-- Actions --> <FileEntryActions ref="actions" :class="`files-list__row-actions-${uniqueId}`" - :files-list-width="filesListWidth" :grid-mode="true" - :loading.sync="loading" :opened.sync="openedMenu" :source="source" /> </tr> </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 { getUploader } from '@nextcloud/upload' -import { showError } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' -import { generateUrl } from '@nextcloud/router' -import { vOnClickOutside } from '@vueuse/components' -import Vue from 'vue' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' -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 { useNavigation } from '../composables/useNavigation.ts' +import { useRouteParameters } from '../composables/useRouteParameters.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 Vue.extend({ +export default defineComponent({ name: 'FileEntryGrid', components: { @@ -110,23 +91,14 @@ export default Vue.extend({ FileEntryCheckbox, FileEntryName, FileEntryPreview, + NcDateTime, }, + mixins: [ + FileEntryMixin, + ], + 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, - }, - }, setup() { const actionsMenuStore = useActionsMenuStore() @@ -134,282 +106,30 @@ export default Vue.extend({ const filesStore = useFilesStore() const renamingStore = useRenamingStore() const selectionStore = useSelectionStore() + // The file list is guaranteed to be only shown with active view - thus we can set the `loaded` flag + const { currentView } = useNavigation(true) + const { + directory: currentDir, + fileId: currentFileId, + } = useRouteParameters() + return { actionsMenuStore, draggingStore, filesStore, renamingStore, selectionStore, + + currentDir, + currentFileId, + currentView, } }, 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?.toString?.() - }, - 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.selectedFiles.includes(this.fileid) - }, - - isRenaming() { - return this.renamingStore.renamingNode === this.source - }, - - isActive() { - return this.fileid === 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.draggingFiles.includes(this.fileid)) { - return false - } - - return (this.source.permissions & Permission.CREATE) !== 0 - }, - - openedMenu: { - get() { - return this.actionsMenuStore.opened === this.uniqueId - }, - set(opened) { - this.actionsMenuStore.opened = opened ? this.uniqueId : 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) { - event.preventDefault() - event.stopPropagation() - return - } - - logger.debug('Drag started') - - // 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) { - 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?.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) - 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..735490c45b3 --- /dev/null +++ b/apps/files/src/components/FileEntryMixin.ts @@ -0,0 +1,509 @@ +/** + * 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 { extname } from 'path' +import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, getFileActions } from '@nextcloud/files' +import { generateUrl } from '@nextcloud/router' +import { isPublicShare } from '@nextcloud/sharing/public' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { vOnClickOutside } from '@vueuse/components' +import Vue, { computed, defineComponent } from 'vue' + +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' +import { getDragAndDropPreview } from '../utils/dragUtils.ts' +import { hashCode } from '../utils/hashUtils.ts' +import { isDownloadable } from '../utils/permissions.ts' +import logger from '../logger.ts' + +Vue.directive('onClickOutside', vOnClickOutside) + +const actions = getFileActions() + +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, + }, + isMtimeAvailable: { + type: Boolean, + default: false, + }, + compact: { + type: Boolean, + default: false, + }, + }, + + provide() { + return { + defaultFileAction: computed(() => this.defaultFileAction), + enabledFileActions: computed(() => this.enabledFileActions), + } + }, + + data() { + return { + dragover: false, + gridMode: false, + } + }, + + computed: { + fileid() { + return this.source.fileid ?? 0 + }, + + uniqueId() { + return hashCode(this.source.source) + }, + + isLoading() { + return this.source.status === NodeStatus.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(): boolean { + if (this.isRenaming) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + 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(): boolean { + if (this.source.type !== FileType.Folder) { + return false + } + + // Ignore if the node is not available + if (this.isFailedSource) { + 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) { + // If the menu is opened on another file entry, we ignore closed events + if (opened === false && this.actionsMenuStore.opened !== this.uniqueId.toString()) { + return + } + + // If opened, we specify the current file id + // else we set it to null to close the menu + this.actionsMenuStore.opened = opened + ? this.uniqueId.toString() + : null + }, + }, + + mtime() { + // If the mtime is not a valid date, return it as is + if (this.source.mtime && !isNaN(this.source.mtime.getDate())) { + return this.source.mtime + } + + if (this.source.crtime && !isNaN(this.source.crtime.getDate())) { + return this.source.crtime + } + + return null + }, + + mtimeOpacity() { + if (!this.mtime) { + return {} + } + + // The time when we start reducing the opacity + const maxOpacityTime = 31 * 24 * 60 * 60 * 1000 // 31 days + // everything older than the maxOpacityTime will have the same value + const timeDiff = Date.now() - this.mtime.getTime() + if (timeDiff < 0) { + // this means we have an invalid mtime which is in the future! + return {} + } + + // inversed time difference from 0 to maxOpacityTime (which would mean today) + const opacityTime = Math.max(0, maxOpacityTime - timeDiff) + // 100 = today, 0 = 31 days ago or older + const percentage = Math.round(opacityTime * 100 / maxOpacityTime) + return { + color: `color-mix(in srgb, var(--color-main-text) ${percentage}%, var(--color-text-maxcontrast))`, + } + }, + + /** + * Sorted actions that are enabled for this node + */ + enabledFileActions() { + if (this.source.status === NodeStatus.FAILED) { + return [] + } + + return actions + .filter(action => { + if (!action.enabled) { + return true + } + + // In case something goes wrong, since we don't want to break + // the entire list, we filter out actions that throw an error. + try { + return action.enabled([this.source], this.currentView) + } catch (error) { + logger.error('Error while checking action', { action, error }) + return false + } + }) + .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 newSource The new value of the source prop + * @param oldSource The previous value + */ + source(newSource: Node, oldSource: Node) { + if (newSource.source !== oldSource.source) { + this.resetState() + } + }, + + openedMenu() { + // Checking if the menu is really closed and not + // just a change in the open state to another file entry. + if (this.actionsMenuStore.opened === null) { + // Reset any right menu position potentially set + logger.debug('All actions menu closed, resetting right menu position...') + const root = this.$el?.closest('main.app-content') as HTMLElement + if (root !== null) { + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + } + }, + }, + + beforeDestroy() { + this.resetState() + }, + + methods: { + resetState() { + // 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 + } + + // Ignore right click if the node is not available + if (this.isFailedSource) { + 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 + logger.debug('Setting actions menu position...') + 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: MouseEvent) { + // Ignore click if we are renaming + if (this.isRenaming) { + return + } + + // Ignore right click (button & 2) and any auxiliary button expect mouse-wheel (button & 4) + if (Boolean(event.button & 2) || event.button > 4) { + return + } + + // Ignore if the node is not available + if (this.isFailedSource) { + return + } + + // if ctrl+click / cmd+click (MacOS uses the meta key) or middle mouse button (button & 4), open in new tab + // also if there is no default action use this as a fallback + const metaKeyPressed = event.ctrlKey || event.metaKey || event.button === 1 + if (metaKeyPressed || !this.defaultFileAction) { + // If no download permission, then we can not allow to download (direct link) the files + if (isPublicShare() && !isDownloadable(this.source)) { + return + } + + const url = isPublicShare() + ? this.source.encodedSource + : generateUrl('/f/{fileId}', { fileId: this.fileid }) + event.preventDefault() + event.stopPropagation() + + // Open the file in a new tab if the meta key or the middle mouse button is clicked + window.open(url, metaKeyPressed ? '_blank' : '_self') + return + } + + // every special case handled so just execute the default action + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.defaultFileAction.exec(this.source, this.currentView, this.currentDir) + }, + + 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 (selection.length === 0 && 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, + }, +}) diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue new file mode 100644 index 00000000000..bd3ac867ed5 --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilter.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcActions force-menu + :type="isActive ? 'secondary' : 'tertiary'" + :menu-name="filterName"> + <template #icon> + <slot name="icon" /> + </template> + <slot /> + + <template v-if="isActive"> + <NcActionSeparator /> + <NcActionButton class="files-list-filter__clear-button" + close-after-click + @click="$emit('reset-filter')"> + {{ t('files', 'Clear filter') }} + </NcActionButton> + </template> + </NcActions> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' + +defineProps<{ + isActive: boolean + filterName: string +}>() + +defineEmits<{ + (event: 'reset-filter'): void +}>() +</script> + +<style scoped> +.files-list-filter__clear-button :deep(.action-button__text) { + color: var(--color-error-text); +} + +:deep(.button-vue) { + font-weight: normal !important; + + * { + font-weight: normal !important; + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue new file mode 100644 index 00000000000..3a843b2bc3e --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterModified.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter :is-active="isActive" + :filter-name="t('files', 'Modified')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiCalendarRangeOutline" /> + </template> + <NcActionButton v-for="preset of timePresets" + :key="preset.id" + type="radio" + close-after-click + :model-value.sync="selectedOption" + :value="preset.id"> + {{ preset.label }} + </NcActionButton> + <!-- TODO: Custom time range --> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITimePreset } from '../../filters/ModifiedFilter.ts' + +import { mdiCalendarRangeOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + timePresets: { + type: Array as PropType<ITimePreset[]>, + required: true, + }, + }, + + setup() { + return { + // icons used in template + mdiCalendarRangeOutline, + } + }, + + data() { + return { + selectedOption: null as string | null, + timeRangeEnd: null as number | null, + timeRangeStart: null as number | null, + } + }, + + computed: { + /** + * Is the filter currently active + */ + isActive() { + return this.selectedOption !== null + }, + + currentPreset() { + return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null + }, + }, + + watch: { + selectedOption() { + if (this.selectedOption === null) { + this.$emit('update:preset') + } else { + const preset = this.currentPreset + this.$emit('update:preset', preset) + } + }, + }, + + methods: { + t, + + resetFilter() { + this.selectedOption = null + this.timeRangeEnd = null + this.timeRangeStart = null + }, + }, +}) +</script> + +<style scoped lang="scss"> +.files-list-filter-time { + &__clear-button :deep(.action-button__text) { + color: var(--color-error-text); + } +} +</style> diff --git a/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue new file mode 100644 index 00000000000..938be171f6d --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterToSearch.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcButton v-show="isVisible" @click="onClick"> + {{ t('files', 'Search everywhere') }} + </NcButton> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getPinia } from '../../store/index.ts' +import { useSearchStore } from '../../store/search.ts' + +const isVisible = ref(false) + +defineExpose({ + hideButton, + showButton, +}) + +/** + * Hide the button - called by the filter class + */ +function hideButton() { + isVisible.value = false +} + +/** + * Show the button - called by the filter class + */ +function showButton() { + isVisible.value = true +} + +/** + * Button click handler to make the filtering a global search. + */ +function onClick() { + const searchStore = useSearchStore(getPinia()) + searchStore.scope = 'globally' +} +</script> diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue new file mode 100644 index 00000000000..d3ad791513f --- /dev/null +++ b/apps/files/src/components/FileListFilter/FileListFilterType.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter class="file-list-filter-type" + :is-active="isActive" + :filter-name="t('files', 'Type')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiFileOutline" /> + </template> + <NcActionButton v-for="fileType of typePresets" + :key="fileType.id" + type="checkbox" + :model-value="selectedOptions.includes(fileType)" + @click="toggleOption(fileType)"> + <template #icon> + <NcIconSvgWrapper :svg="fileType.icon" /> + </template> + {{ fileType.label }} + </NcActionButton> + </FileListFilter> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { ITypePreset } from '../../filters/TypeFilter.ts' + +import { mdiFileOutline } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import FileListFilter from './FileListFilter.vue' + +export default defineComponent({ + name: 'FileListFilterType', + + components: { + FileListFilter, + NcActionButton, + NcIconSvgWrapper, + }, + + props: { + presets: { + type: Array as PropType<ITypePreset[]>, + default: () => [], + }, + typePresets: { + type: Array as PropType<ITypePreset[]>, + required: true, + }, + }, + + setup() { + return { + mdiFileOutline, + t, + } + }, + + data() { + return { + selectedOptions: [] as ITypePreset[], + } + }, + + computed: { + isActive() { + return this.selectedOptions.length > 0 + }, + }, + + watch: { + /** Reset selected options if property is changed */ + presets() { + this.selectedOptions = this.presets ?? [] + }, + selectedOptions(newValue, oldValue) { + if (this.selectedOptions.length === 0) { + if (oldValue.length !== 0) { + this.$emit('update:presets') + } + } else { + this.$emit('update:presets', this.selectedOptions) + } + }, + }, + + mounted() { + this.selectedOptions = this.presets ?? [] + }, + + methods: { + resetFilter() { + this.selectedOptions = [] + }, + + /** + * Toggle option from selected option + * @param option The option to toggle + */ + toggleOption(option: ITypePreset) { + const idx = this.selectedOptions.indexOf(option) + if (idx !== -1) { + this.selectedOptions.splice(idx, 1) + } else { + this.selectedOptions.push(option) + } + }, + }, +}) +</script> + +<style> +.file-list-filter-type { + max-width: 220px; +} +</style> diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue new file mode 100644 index 00000000000..7f0d71fd85a --- /dev/null +++ b/apps/files/src/components/FileListFilters.vue @@ -0,0 +1,74 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="file-list-filters"> + <div class="file-list-filters__filter" data-cy-files-filters> + <span v-for="filter of visualFilters" + :key="filter.id" + ref="filterElements" /> + </div> + <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')"> + <li v-for="(chip, index) of activeChips" :key="index"> + <NcChip :aria-label-close="t('files', 'Remove filter')" + :icon-svg="chip.icon" + :text="chip.text" + @close="chip.onclick"> + <template v-if="chip.user" #icon> + <NcAvatar disable-menu + :show-user-status="false" + :size="24" + :user="chip.user" /> + </template> + </NcChip> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +import { t } from '@nextcloud/l10n' +import { computed, ref, watchEffect } from 'vue' +import { useFiltersStore } from '../store/filters.ts' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcChip from '@nextcloud/vue/components/NcChip' + +const filterStore = useFiltersStore() +const visualFilters = computed(() => filterStore.filtersWithUI) +const activeChips = computed(() => filterStore.activeChips) + +const filterElements = ref<HTMLElement[]>([]) +watchEffect(() => { + filterElements.value + .forEach((el, index) => visualFilters.value[index].mount(el)) +}) +</script> + +<style scoped lang="scss"> +.file-list-filters { + display: flex; + flex-direction: column; + gap: var(--default-grid-baseline); + height: 100%; + width: 100%; + + &__filter { + display: flex; + align-items: start; + justify-content: start; + gap: calc(var(--default-grid-baseline, 4px) * 2); + + > * { + flex: 0 1 fit-content; + } + } + + &__active { + display: flex; + flex-direction: row; + gap: calc(var(--default-grid-baseline, 4px) * 2); + } +} +</style> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 3639571411a..31458398028 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-show="enabled" :class="`files-list__header-${header.id}`"> <span ref="mount" /> @@ -26,6 +9,13 @@ </template> <script lang="ts"> +import type { Folder, Header, View } from '@nextcloud/files' +import type { PropType } from 'vue' + +import PQueue from 'p-queue' + +import logger from '../logger.ts' + /** * This component is used to render custom * elements provided by an API. Vue doesn't allow @@ -36,21 +26,29 @@ export default { name: 'FilesListHeader', props: { header: { - type: Object, + type: Object as PropType<Header>, required: true, }, currentFolder: { - type: Object, + type: Object as PropType<Folder>, required: true, }, currentView: { - type: Object, + type: Object as PropType<View>, required: true, }, }, + setup() { + // Create a queue to ensure that the header is only rendered once at a time + const queue = new PQueue({ concurrency: 1 }) + + return { + queue, + } + }, computed: { enabled() { - return this.header.enabled(this.currentFolder, this.currentView) + return this.header.enabled?.(this.currentFolder, this.currentView) ?? true }, }, watch: { @@ -58,15 +56,45 @@ export default { if (!enabled) { return } - this.header.updated(this.currentFolder, this.currentView) + // If the header is enabled, we need to render it + logger.debug(`Enabled ${this.header.id} FilesListHeader`, { header: this.header }) + this.queueUpdate(this.currentFolder, this.currentView) + }, + currentFolder(folder: Folder) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queueUpdate(folder, this.currentView) }, - currentFolder() { - this.header.updated(this.currentFolder, this.currentView) + currentView(view: View) { + this.queueUpdate(this.currentFolder, view) }, }, + mounted() { - console.debug('Mounted', this.header.id) - this.header.render(this.$refs.mount, this.currentFolder, this.currentView) + logger.debug(`Mounted ${this.header.id} FilesListHeader`, { header: this.header }) + const initialRender = () => this.header.render(this.$refs.mount as HTMLElement, this.currentFolder, this.currentView) + this.queue.add(initialRender).then(() => { + logger.debug(`Rendered ${this.header.id} FilesListHeader`, { header: this.header }) + }).catch((error) => { + logger.error(`Error rendering ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, + destroyed() { + logger.debug(`Destroyed ${this.header.id} FilesListHeader`, { header: this.header }) + }, + + methods: { + queueUpdate(currentFolder: Folder, currentView: View) { + // This method can be used to queue an update of the header + // It will ensure that the header is only updated once at a time + this.queue.add(() => this.header.updated(currentFolder, currentView)) + .then(() => { + logger.debug(`Updated ${this.header.id} FilesListHeader`, { header: this.header }) + }) + .catch((error) => { + logger.error(`Error updating ${this.header.id} FilesListHeader`, { header: this.header, error }) + }) + }, }, } </script> diff --git a/apps/files/src/components/FilesListTableFooter.vue b/apps/files/src/components/FilesListTableFooter.vue index 9580de29919..9e8cdc159ee 100644 --- a/apps/files/src/components/FilesListTableFooter.vue +++ b/apps/files/src/components/FilesListTableFooter.vue @@ -1,24 +1,7 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <tr> <th class="files-list__row-checkbox"> @@ -38,6 +21,10 @@ <!-- Actions --> <td class="files-list__row-actions" /> + <!-- Mime --> + <td v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" /> + <!-- Size --> <td v-if="isSizeAvailable" class="files-list__column files-list__row-size"> @@ -58,20 +45,29 @@ </template> <script lang="ts"> -import { formatFileSize } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' + +import { View, formatFileSize } from '@nextcloud/files' import { translate } from '@nextcloud/l10n' -import Vue from 'vue' +import { defineComponent } from 'vue' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' -export default Vue.extend({ +export default defineComponent({ name: 'FilesListTableFooter', - components: { - }, - props: { + currentView: { + type: View, + required: true, + }, + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, @@ -81,7 +77,7 @@ export default Vue.extend({ default: false, }, nodes: { - type: Array, + type: Array as PropType<Node[]>, required: true, }, summary: { @@ -97,31 +93,24 @@ export default Vue.extend({ setup() { const pathsStore = usePathsStore() const filesStore = useFilesStore() + const { directory } = useRouteParameters() return { filesStore, pathsStore, + directory, } }, computed: { - currentView() { - return this.$navigation.active - }, - - dir() { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') - }, - currentFolder() { if (!this.currentView?.id) { return } - if (this.dir === '/') { + if (this.directory === '/') { return this.filesStore.getRoot(this.currentView.id) } - const fileId = this.pathsStore.getPath(this.currentView.id, this.dir) + const fileId = this.pathsStore.getPath(this.currentView.id, this.directory)! return this.filesStore.getNode(fileId) }, @@ -140,7 +129,7 @@ export default Vue.extend({ } // Otherwise let's compute it - return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true) + return formatFileSize(this.nodes.reduce((total, node) => total + (node.size ?? 0), 0), true) }, }, @@ -160,7 +149,7 @@ export default Vue.extend({ <style scoped lang="scss"> // Scoped row tr { - margin-bottom: 300px; + margin-bottom: var(--body-container-margin); border-top: 1px solid var(--color-border); // Prevent hover effect on the whole row background-color: transparent !important; diff --git a/apps/files/src/components/FilesListTableHeader.vue b/apps/files/src/components/FilesListTableHeader.vue index 148ce3bc4e5..23e631199eb 100644 --- a/apps/files/src/components/FilesListTableHeader.vue +++ b/apps/files/src/components/FilesListTableHeader.vue @@ -1,29 +1,12 @@ <!-- - - @copyright Copyright (c) 2023 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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <tr class="files-list__row-head"> <th class="files-list__column files-list__row-checkbox" @keyup.esc.exact="resetSelection"> - <NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" /> + <NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" /> </th> <!-- Columns display --> @@ -41,6 +24,14 @@ <!-- Actions --> <th class="files-list__row-actions" /> + <!-- Mime --> + <th v-if="isMimeAvailable" + class="files-list__column files-list__row-mime" + :class="{ 'files-list__column--sortable': isMimeAvailable }" + :aria-sort="ariaSortForMode('mime')"> + <FilesListTableHeaderButton :name="t('files', 'File type')" mode="mime" /> + </th> + <!-- Size --> <th v-if="isSizeAvailable" class="files-list__column files-list__row-size" @@ -71,24 +62,28 @@ </template> <script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types.ts' + import { translate as t } from '@nextcloud/l10n' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import Vue from 'vue' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import { useFilesStore } from '../store/files.ts' +import { useNavigation } from '../composables/useNavigation' import { useSelectionStore } from '../store/selection.ts' -import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import filesSortingMixin from '../mixins/filesSorting.ts' -import logger from '../logger.js' +import logger from '../logger.ts' -export default Vue.extend({ +export default defineComponent({ name: 'FilesListTableHeader', components: { FilesListTableHeaderButton, NcCheckboxRadioSwitch, - FilesListTableHeaderActions, }, mixins: [ @@ -96,6 +91,10 @@ export default Vue.extend({ ], props: { + isMimeAvailable: { + type: Boolean, + default: false, + }, isMtimeAvailable: { type: Boolean, default: false, @@ -105,7 +104,7 @@ export default Vue.extend({ default: false, }, nodes: { - type: Array, + type: Array as PropType<Node[]>, required: true, }, filesListWidth: { @@ -117,17 +116,17 @@ export default Vue.extend({ setup() { const filesStore = useFilesStore() const selectionStore = useSelectionStore() + const { currentView } = useNavigation() + return { filesStore, selectionStore, + + currentView, } }, computed: { - currentView() { - return this.$navigation.active - }, - columns() { // Hide columns if the list is too small if (this.filesListWidth < 512) { @@ -168,8 +167,23 @@ export default Vue.extend({ }, }, + created() { + // ctrl+a selects all + useHotKey('a', this.onToggleAll, { + ctrl: true, + stop: true, + prevent: true, + }) + + // Escape key cancels selection + useHotKey('Escape', this.resetSelection, { + stop: true, + prevent: true, + }) + }, + methods: { - ariaSortForMode(mode: string): ARIAMixin['ariaSort'] { + ariaSortForMode(mode: string): 'ascending'|'descending'|null { if (this.sortingMode === mode) { return this.isAscSorting ? 'ascending' : 'descending' } @@ -181,13 +195,13 @@ export default Vue.extend({ 'files-list__column': true, 'files-list__column--sortable': !!column.sort, 'files-list__row-column-custom': true, - [`files-list__row-${this.currentView.id}-${column.id}`]: true, + [`files-list__row-${this.currentView?.id}-${column.id}`]: true, } }, - onToggleAll(selected) { + onToggleAll(selected = true) { if (selected) { - const selection = this.nodes.map(node => node.fileid.toString()) + const selection = this.nodes.map(node => node.source).filter(Boolean) as FileSource[] logger.debug('Added all nodes to selection', { selection }) this.selectionStore.setLastIndex(null) this.selectionStore.set(selection) @@ -198,6 +212,9 @@ export default Vue.extend({ }, resetSelection() { + if (this.isNoneSelected) { + return + } this.selectionStore.reset() }, diff --git a/apps/files/src/components/FilesListTableHeaderActions.vue b/apps/files/src/components/FilesListTableHeaderActions.vue index 296be604820..6a808355c58 100644 --- a/apps/files/src/components/FilesListTableHeaderActions.vue +++ b/apps/files/src/components/FilesListTableHeaderActions.vue @@ -1,35 +1,31 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="files-list__column files-list__row-actions-batch"> + <div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions> <NcActions ref="actionsMenu" + container="#app-content-vue" + :boundaries-element="boundariesElement" :disabled="!!loading || areSomeNodesLoading" :force-name="true" - :inline="inlineActions" - :menu-name="inlineActions <= 1 ? t('files', 'Actions') : null" - :open.sync="openedMenu"> - <NcActionButton v-for="action in enabledActions" + :inline="enabledInlineActions.length" + :menu-name="enabledInlineActions.length <= 1 ? t('files', 'Actions') : null" + :open.sync="openedMenu" + @close="openedSubmenu = null"> + <!-- Default actions list--> + <NcActionButton v-for="action in enabledMenuActions" :key="action.id" - :class="'files-list__row-actions-batch-' + action.id" + :ref="`action-batch-${action.id}`" + :class="{ + [`files-list__row-actions-batch-${action.id}`]: true, + [`files-list__row-actions-batch--menu`]: isValidMenu(action) + }" + :close-after-click="!isValidMenu(action)" + :data-cy-files-list-selection-action="action.id" + :is-menu="isValidMenu(action)" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" @click="onActionClick(action)"> <template #icon> <NcLoadingIcon v-if="loading === action.id" :size="18" /> @@ -37,50 +33,86 @@ </template> {{ action.displayName(nodes, currentView) }} </NcActionButton> + + <!-- Submenu actions list--> + <template v-if="openedSubmenu && enabledSubmenuActions[openedSubmenu?.id]"> + <!-- Back to top-level button --> + <NcActionButton class="files-list__row-actions-batch-back" data-cy-files-list-selection-action="menu-back" @click="onBackToMenuClick(openedSubmenu)"> + <template #icon> + <ArrowLeftIcon /> + </template> + {{ t('files', 'Back') }} + </NcActionButton> + <NcActionSeparator /> + + <!-- Submenu actions --> + <NcActionButton v-for="action in enabledSubmenuActions[openedSubmenu?.id]" + :key="action.id" + :class="`files-list__row-actions-batch-${action.id}`" + class="files-list__row-actions-batch--submenu" + close-after-click + :data-cy-files-list-selection-action="action.id" + :aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */" + :title="action.title?.(nodes, currentView)" + @click="onActionClick(action)"> + <template #icon> + <NcLoadingIcon v-if="loading === action.id" :size="18" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline(nodes, currentView)" /> + </template> + {{ action.displayName(nodes, currentView) }} + </NcActionButton> + </template> </NcActions> </div> </template> <script lang="ts"> -import { NodeStatus, getFileActions } from '@nextcloud/files' +import type { FileAction, Node, View } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { FileSource } from '../types' + +import { getFileActions, NodeStatus, DefaultType } from '@nextcloud/files' import { showError, showSuccess } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import Vue from 'vue' +import { defineComponent } from 'vue' + +import ArrowLeftIcon from 'vue-material-design-icons/ArrowLeft.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import { useRouteParameters } from '../composables/useRouteParameters.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' import { useSelectionStore } from '../store/selection.ts' -import filesListWidthMixin from '../mixins/filesListWidth.ts' -import logger from '../logger.js' +import actionsMixins from '../mixins/actionsMixin.ts' +import logger from '../logger.ts' // The registered actions list const actions = getFileActions() -export default Vue.extend({ +export default defineComponent({ name: 'FilesListTableHeaderActions', components: { + ArrowLeftIcon, NcActions, NcActionButton, NcIconSvgWrapper, NcLoadingIcon, }, - mixins: [ - filesListWidthMixin, - ], + mixins: [actionsMixins], props: { currentView: { - type: Object, + type: Object as PropType<View>, required: true, }, selectedNodes: { - type: Array, + type: Array as PropType<FileSource[]>, default: () => ([]), }, }, @@ -89,10 +121,20 @@ export default Vue.extend({ const actionsMenuStore = useActionsMenuStore() const filesStore = useFilesStore() const selectionStore = useSelectionStore() + const fileListWidth = useFileListWidth() + const { directory } = useRouteParameters() + + const boundariesElement = document.getElementById('app-content-vue') + return { + directory, + fileListWidth, + actionsMenuStore, filesStore, selectionStore, + + boundariesElement, } }, @@ -103,21 +145,82 @@ export default Vue.extend({ }, computed: { - dir() { - // Remove any trailing slash but leave root slash - return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') - }, - enabledActions() { + enabledFileActions(): FileAction[] { return actions - .filter(action => action.execBatch) + // We don't handle renderInline actions in this component + .filter(action => !action.renderInline) + // We don't handle actions that are not visible + .filter(action => action.default !== DefaultType.HIDDEN) .filter(action => !action.enabled || action.enabled(this.nodes, this.currentView)) .sort((a, b) => (a.order || 0) - (b.order || 0)) }, + /** + * Return the list of enabled actions that are + * allowed to be rendered inlined. + * This means that they are not within a menu, nor + * being the parent of submenu actions. + */ + enabledInlineActions(): FileAction[] { + return this.enabledFileActions + // Remove all actions that are not top-level actions + .filter(action => action.parent === undefined) + // Remove all actions that are not batch actions + .filter(action => action.execBatch !== undefined) + // Remove all top-menu entries + .filter(action => !this.isValidMenu(action)) + // Return a maximum actions to fit the screen + .slice(0, this.inlineActions) + }, + + /** + * Return the rest of enabled actions that are not + * rendered inlined. + */ + enabledMenuActions(): FileAction[] { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + // We filter duplicates to prevent inline actions to be shown twice + const actions = this.enabledFileActions.filter((value, index, self) => { + return index === self.findIndex(action => action.id === value.id) + }) + + // Generate list of all top-level actions ids + const childrenActionsIds = actions.filter(action => action.parent).map(action => action.parent) as string[] + + const menuActions = actions + .filter(action => { + // If the action is not a batch action, we need + // to make sure it's a top-level parent entry + // and that we have some children actions bound to it + if (!action.execBatch) { + return childrenActionsIds.includes(action.id) + } + + // Rendering second-level actions is done in the template + // when openedSubmenu is set. + if (action.parent) { + return false + } + + return true + }) + .filter(action => !this.enabledInlineActions.includes(action)) + + // Make sure we render the inline actions first + // and then the rest of the actions. + // We do NOT want nested actions to be rendered inlined + return [...this.enabledInlineActions, ...menuActions] + }, + nodes() { return this.selectedNodes - .map(fileid => this.getNode(fileid)) - .filter(node => node) + .map(source => this.getNode(source)) + .filter(Boolean) as Node[] }, areSomeNodesLoading() { @@ -134,13 +237,13 @@ export default Vue.extend({ }, inlineActions() { - if (this.filesListWidth < 512) { + if (this.fileListWidth < 512) { return 0 } - if (this.filesListWidth < 768) { + if (this.fileListWidth < 768) { return 1 } - if (this.filesListWidth < 1024) { + if (this.fileListWidth < 1024) { return 2 } return 3 @@ -151,25 +254,36 @@ export default Vue.extend({ /** * Get a cached note from the store * - * @param {number} fileId the file id to get - * @return {Folder|File} + * @param source The source of the node to get */ - getNode(fileId) { - return this.filesStore.getNode(fileId) + getNode(source: string): Node|undefined { + return this.filesStore.getNode(source) }, async onActionClick(action) { - const displayName = action.displayName(this.nodes, this.currentView) - const selectionIds = this.selectedNodes + // If the action is a submenu, we open it + if (this.enabledSubmenuActions[action.id]) { + this.openedSubmenu = action + return + } + + let displayName = action.id + try { + displayName = action.displayName(this.nodes, this.currentView) + } catch (error) { + logger.error('Error while getting action display name', { action, error }) + } + + const selectionSources = this.selectedNodes try { // Set loading markers this.loading = action.id this.nodes.forEach(node => { - Vue.set(node, 'status', NodeStatus.LOADING) + this.$set(node, 'status', NodeStatus.LOADING) }) // Dispatch action execution - const results = await action.execBatch(this.nodes, this.currentView, this.dir) + const results = await action.execBatch(this.nodes, this.currentView, this.directory) // Check if all actions returned null if (!results.some(result => result !== null)) { @@ -181,9 +295,9 @@ export default Vue.extend({ // Handle potential failures if (results.some(result => result === false)) { // Remove the failed ids from the selection - const failedIds = selectionIds - .filter((fileid, index) => results[index] === false) - this.selectionStore.set(failedIds) + const failedSources = selectionSources + .filter((source, index) => results[index] === false) + this.selectionStore.set(failedSources) if (results.some(result => result === null)) { // If some actions returned null, we assume that the dev @@ -191,21 +305,21 @@ export default Vue.extend({ return } - showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) + showError(this.t('files', '{displayName}: failed on some elements', { displayName })) return } // Show success message and clear selection - showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName })) + showSuccess(this.t('files', '{displayName}: done', { displayName })) this.selectionStore.reset() } catch (e) { logger.error('Error while executing action', { action, e }) - showError(this.t('files', '"{displayName}" action failed', { displayName })) + showError(this.t('files', '{displayName}: failed', { displayName })) } finally { // Remove loading markers this.loading = null this.nodes.forEach(node => { - Vue.set(node, 'status', undefined) + this.$set(node, 'status', undefined) }) } }, diff --git a/apps/files/src/components/FilesListTableHeaderButton.vue b/apps/files/src/components/FilesListTableHeaderButton.vue index 955f25016ab..d2e14a5495f 100644 --- a/apps/files/src/components/FilesListTableHeaderButton.vue +++ b/apps/files/src/components/FilesListTableHeaderButton.vue @@ -1,25 +1,7 @@ <!-- - - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - @author Ferdinand Thiessen <opensource@fthiessen.de> - - - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcButton :class="['files-list__column-sort-button', { 'files-list__column-sort-button--active': sortingMode === mode, @@ -27,6 +9,7 @@ }]" :alignment="mode === 'size' ? 'end' : 'start-reverse'" type="tertiary" + :title="name" @click="toggleSortBy(mode)"> <template #icon> <MenuUp v-if="sortingMode !== mode || isAscSorting" class="files-list__column-sort-button-icon" /> @@ -42,7 +25,7 @@ import { defineComponent } from 'vue' import MenuDown from 'vue-material-design-icons/MenuDown.vue' import MenuUp from 'vue-material-design-icons/MenuUp.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' import filesSortingMixin from '../mixins/filesSorting.ts' @@ -79,7 +62,7 @@ export default defineComponent({ <style scoped lang="scss"> .files-list__column-sort-button { // Compensate for cells margin - margin: 0 calc(var(--cell-margin) * -1); + margin: 0 calc(var(--button-padding, var(--cell-margin)) * -1); min-width: calc(100% - 3 * var(--cell-margin))!important; &-text { diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index ed0096e9792..47b8ef19b19 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2023 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/>. - - - --> + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <VirtualList ref="table" :data-component="userConfig.grid_view ? FileEntryGrid : FileEntry" @@ -26,21 +9,28 @@ :data-sources="nodes" :grid-mode="userConfig.grid_view" :extra-props="{ + isMimeAvailable, isMtimeAvailable, isSizeAvailable, nodes, - filesListWidth, }" :scroll-to-index="scrollToIndex" :caption="caption"> + <template #filters> + <FileListFilters /> + </template> + <template v-if="!isNoneSelected" #header-overlay> + <span class="files-list__selected"> + {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + </span> <FilesListTableHeaderActions :current-view="currentView" :selected-nodes="selectedNodes" /> </template> <template #before> <!-- Headers --> - <FilesListHeader v-for="header in sortedHeaders" + <FilesListHeader v-for="header in headers" :key="header.id" :current-folder="currentFolder" :current-view="currentView" @@ -51,15 +41,23 @@ <template #header> <!-- Table header and sort buttons --> <FilesListTableHeader ref="thead" - :files-list-width="filesListWidth" + :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" /> </template> + <!-- Body replacement if no files are available --> + <template #empty> + <slot name="empty" /> + </template> + <!-- Tfoot--> <template #footer> - <FilesListTableFooter :files-list-width="filesListWidth" + <FilesListTableFooter :current-view="currentView" + :files-list-width="fileListWidth" + :is-mime-available="isMimeAvailable" :is-mtime-available="isMtimeAvailable" :is-size-available="isSizeAvailable" :nodes="nodes" @@ -69,35 +67,40 @@ </template> <script lang="ts"> -import type { Node as NcNode } from '@nextcloud/files' -import type { PropType } from 'vue' import type { UserConfig } from '../types' +import type { Node as NcNode } from '@nextcloud/files' +import type { ComponentPublicInstance, PropType } from 'vue' -import { getFileListHeaders, Folder, View, getFileActions } from '@nextcloud/files' +import { Folder, Permission, View, getFileActions, FileType } from '@nextcloud/files' import { showError } from '@nextcloud/dialogs' -import { loadState } from '@nextcloud/initial-state' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { n, t } from '@nextcloud/l10n' +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' import { defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' -import { getSummaryFor } from '../utils/fileUtils' +import { useActiveStore } from '../store/active.ts' +import { useFileListHeaders } from '../composables/useFileListHeaders.ts' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import { useRouteParameters } from '../composables/useRouteParameters.ts' import { useSelectionStore } from '../store/selection.js' import { useUserConfigStore } from '../store/userconfig.ts' +import logger from '../logger.ts' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' +import FileListFilters from './FileListFilters.vue' import FilesListHeader from './FilesListHeader.vue' import FilesListTableFooter from './FilesListTableFooter.vue' import FilesListTableHeader from './FilesListTableHeader.vue' -import filesListWidthMixin from '../mixins/filesListWidth.ts' -import VirtualList from './VirtualList.vue' -import logger from '../logger.js' import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue' +import VirtualList from './VirtualList.vue' export default defineComponent({ name: 'FilesListVirtual', components: { + FileListFilters, FilesListHeader, FilesListTableFooter, FilesListTableHeader, @@ -105,10 +108,6 @@ export default defineComponent({ FilesListTableHeaderActions, }, - mixins: [ - filesListWidthMixin, - ], - props: { currentView: { type: View, @@ -122,14 +121,33 @@ export default defineComponent({ type: Array as PropType<NcNode[]>, required: true, }, + summary: { + type: String, + required: true, + }, }, setup() { - const userConfigStore = useUserConfigStore() + const activeStore = useActiveStore() const selectionStore = useSelectionStore() + const userConfigStore = useUserConfigStore() + + const fileListWidth = useFileListWidth() + const { fileId, openDetails, openFile } = useRouteParameters() + return { - userConfigStore, + fileId, + fileListWidth, + headers: useFileListHeaders(), + openDetails, + openFile, + + activeStore, selectionStore, + userConfigStore, + + n, + t, } }, @@ -137,7 +155,6 @@ export default defineComponent({ return { FileEntry, FileEntryGrid, - headers: getFileListHeaders(), scrollToIndex: 0, } }, @@ -147,43 +164,53 @@ export default defineComponent({ return this.userConfigStore.userConfig }, - fileId() { - return parseInt(this.$route.params.fileid) || null - }, - - summary() { - return getSummaryFor(this.nodes) + isMimeAvailable() { + if (!this.userConfig.show_mime_column) { + return false + } + // Hide mime column on narrow screens + if (this.fileListWidth < 1024) { + return false + } + return this.nodes.some(node => node.mime !== undefined || node.mime !== 'application/octet-stream') }, - isMtimeAvailable() { // Hide mtime column on narrow screens - if (this.filesListWidth < 768) { + if (this.fileListWidth < 768) { return false } return this.nodes.some(node => node.mtime !== undefined) }, isSizeAvailable() { // Hide size column on narrow screens - if (this.filesListWidth < 768) { + if (this.fileListWidth < 768) { return false } - return this.nodes.some(node => node.attributes.size !== undefined) + return this.nodes.some(node => node.size !== undefined) }, - sortedHeaders() { - if (!this.currentFolder || !this.currentView) { - return [] - } + cantUpload() { + return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) === 0 + }, - return [...this.headers].sort((a, b) => a.order - b.order) + isQuotaExceeded() { + return this.currentFolder?.attributes?.['quota-available-bytes'] === 0 }, caption() { const defaultCaption = t('files', 'List of files and folders.') const viewCaption = this.currentView.caption || defaultCaption + const cantUploadCaption = this.cantUpload ? t('files', 'You do not have permission to upload or create files here.') : null + const quotaExceededCaption = this.isQuotaExceeded ? t('files', 'You have used your space quota and cannot upload files anymore.') : null const sortableCaption = t('files', 'Column headers with buttons are sortable.') const virtualListNote = t('files', 'This list is not fully rendered for performance reasons. The files will be rendered as you navigate through the list.') - return `${viewCaption}\n${sortableCaption}\n${virtualListNote}` + return [ + viewCaption, + cantUploadCaption, + quotaExceededCaption, + sortableCaption, + virtualListNote, + ].filter(Boolean).join('\n') }, selectedNodes() { @@ -193,74 +220,179 @@ export default defineComponent({ isNoneSelected() { return this.selectedNodes.length === 0 }, + + isEmpty() { + return this.nodes.length === 0 + }, }, watch: { - fileId(fileId) { - this.scrollToFile(fileId, false) + // If nodes gets populated and we have a fileId, + // an openFile or openDetails, we fire the appropriate actions. + isEmpty() { + this.handleOpenQueries() + }, + fileId() { + this.handleOpenQueries() + }, + openFile() { + this.handleOpenQueries() + }, + openDetails() { + this.handleOpenQueries() }, }, + created() { + useHotKey('Escape', this.unselectFile, { + stop: true, + prevent: true, + }) + + useHotKey(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'], this.onKeyDown, { + stop: true, + prevent: true, + }) + }, + mounted() { // Add events on parent to cover both the table and DragAndDrop notice const mainContent = window.document.querySelector('main.app-content') as HTMLElement mainContent.addEventListener('dragover', this.onDragOver) - - this.scrollToFile(this.fileId) - this.openSidebarForFile(this.fileId) - this.handleOpenFile() + subscribe('files:sidebar:closed', this.onSidebarClosed) }, beforeDestroy() { const mainContent = window.document.querySelector('main.app-content') as HTMLElement mainContent.removeEventListener('dragover', this.onDragOver) + unsubscribe('files:sidebar:closed', this.onSidebarClosed) }, methods: { - // Open the file sidebar if we have the room for it - // but don't open the sidebar for the current folder + handleOpenQueries() { + // If the list is empty, or we don't have a fileId, + // there's nothing to be done. + if (this.isEmpty || !this.fileId) { + return + } + + logger.debug('FilesListVirtual: checking for requested fileId, openFile or openDetails', { + nodes: this.nodes, + fileId: this.fileId, + openFile: this.openFile, + openDetails: this.openDetails, + }) + + if (this.openFile) { + this.handleOpenFile(this.fileId) + } + + if (this.openDetails) { + this.openSidebarForFile(this.fileId) + } + + if (this.fileId) { + this.scrollToFile(this.fileId, false) + } + }, + openSidebarForFile(fileId) { - if (document.documentElement.clientWidth > 1024 && this.currentFolder.fileid !== fileId) { - // Open the sidebar for the given URL fileid - // iif we just loaded the app. - const node = this.nodes.find(n => n.fileid === fileId) as 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) - } + // Open the sidebar for the given URL fileid + // iif we just loaded the app. + const node = this.nodes.find(n => n.fileid === 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) + return } + logger.warn(`Failed to open sidebar on file ${fileId}, file isn't cached yet !`, { fileId, node }) }, scrollToFile(fileId: number|null, warn = true) { if (fileId) { + // Do not uselessly scroll to the top of the list. + if (fileId === this.currentFolder.fileid) { + return + } + const index = this.nodes.findIndex(node => node.fileid === fileId) if (warn && index === -1 && fileId !== this.currentFolder.fileid) { - showError(this.t('files', 'File not found')) + showError(t('files', 'File not found')) } + this.scrollToIndex = Math.max(0, index) + logger.debug('Scrolling to file ' + fileId, { fileId, index }) } }, - handleOpenFile() { - const openFileInfo = loadState('files', 'openFileInfo', {}) as ({ id?: number }) - if (openFileInfo === undefined) { - return + /** + * Unselect the current file and clear open parameters from the URL + */ + unselectFile() { + const query = { ...this.$route.query } + delete query.openfile + delete query.opendetails + + this.activeStore.activeNode = undefined + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: String(this.currentFolder.fileid ?? '') }, + query, + true, + ) + }, + + // When sidebar is closed, we remove the openDetails parameter from the URL + onSidebarClosed() { + if (this.openDetails) { + const query = { ...this.$route.query } + delete query.opendetails + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + query, + ) } + }, - const node = this.nodes.find(n => n.fileid === openFileInfo.id) as NcNode + /** + * Handle opening a file (e.g. by ?openfile=true) + * @param fileId File to open + */ + async handleOpenFile(fileId: number) { + const node = this.nodes.find(n => n.fileid === fileId) as NcNode if (node === undefined) { return } - logger.debug('Opening file ' + node.path, { node }) - getFileActions() - .filter(action => !action.enabled || action.enabled([node], this.currentView)) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - .filter(action => !!action?.default)[0].exec(node, this.currentView, this.currentFolder.path) - }, - - getFileId(node) { - return node.fileid + if (node.type === FileType.File) { + const defaultAction = getFileActions() + // Get only default actions (visible and hidden) + .filter((action) => !!action?.default) + // Find actions that are either always enabled or enabled for the current node + .filter((action) => !action.enabled || action.enabled([node], this.currentView)) + .filter((action) => action.id !== 'download') + // Sort enabled default actions by order + .sort((a, b) => (a.order || 0) - (b.order || 0)) + // Get the first one + .at(0) + + // Some file types do not have a default action (e.g. they can only be downloaded) + // So if there is an enabled default action, so execute it + if (defaultAction) { + logger.debug('Opening file ' + node.path, { node }) + return await defaultAction.exec(node, this.currentView, this.currentFolder.path) + } + } + // The file is either a folder or has no default action other than downloading + // in this case we need to open the details instead and remove the route from the history + logger.debug('Ignore `openfile` query and replacing with `opendetails` for ' + node.path, { node }) + window.OCP.Files.Router.goToRoute( + null, + this.$route.params, + { ...this.$route.query, openfile: undefined, opendetails: '' }, + true, // silent update of the URL + ) }, onDragOver(event: DragEvent) { @@ -275,41 +407,99 @@ export default defineComponent({ event.preventDefault() event.stopPropagation() - const tableTop = this.$refs.table.$el.getBoundingClientRect().top - const tableBottom = tableTop + this.$refs.table.$el.getBoundingClientRect().height + const tableElement = (this.$refs.table as ComponentPublicInstance<typeof VirtualList>).$el + const tableTop = tableElement.getBoundingClientRect().top + const tableBottom = tableTop + tableElement.getBoundingClientRect().height // If reaching top, scroll up. Using 100 because of the floating header if (event.clientY < tableTop + 100) { - this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop - 25 + tableElement.scrollTop = tableElement.scrollTop - 25 return } // If reaching bottom, scroll down if (event.clientY > tableBottom - 50) { - this.$refs.table.$el.scrollTop = this.$refs.table.$el.scrollTop + 25 + tableElement.scrollTop = tableElement.scrollTop + 25 } }, - t, + onKeyDown(event: KeyboardEvent) { + // Up and down arrow keys + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const columnCount = this.$refs.table?.columnCount ?? 1 + const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0 + const nextIndex = event.key === 'ArrowUp' ? index - columnCount : index + columnCount + if (nextIndex < 0 || nextIndex >= this.nodes.length) { + return + } + + const nextNode = this.nodes[nextIndex] + + if (nextNode && nextNode?.fileid) { + this.setActiveNode(nextNode) + } + } + + // if grid mode, left and right arrow keys + if (this.userConfig.grid_view && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + const index = this.nodes.findIndex(node => node.fileid === this.fileId) ?? 0 + const nextIndex = event.key === 'ArrowLeft' ? index - 1 : index + 1 + if (nextIndex < 0 || nextIndex >= this.nodes.length) { + return + } + + const nextNode = this.nodes[nextIndex] + + if (nextNode && nextNode?.fileid) { + this.setActiveNode(nextNode) + } + } + }, + + setActiveNode(node: NcNode & { fileid: number }) { + logger.debug('Navigating to file ' + node.path, { node, fileid: node.fileid }) + this.scrollToFile(node.fileid) + + // Remove openfile and opendetails from the URL + const query = { ...this.$route.query } + delete query.openfile + delete query.opendetails + + this.activeStore.activeNode = node + + // Silent update of the URL + window.OCP.Files.Router.goToRoute( + null, + { ...this.$route.params, fileid: String(node.fileid) }, + query, + true, + ) + }, }, }) </script> <style scoped lang="scss"> .files-list { - --row-height: 55px; + --row-height: 44px; --cell-margin: 14px; --checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); --checkbox-size: 24px; - --clickable-area: 44px; - --icon-preview-size: 32px; + --clickable-area: var(--default-clickable-area); + --icon-preview-size: 24px; - position: relative; + --fixed-block-start-position: var(--default-clickable-area); + display: flex; + flex-direction: column; overflow: auto; height: 100%; will-change: scroll-position; + &:has(.file-list-filters__active) { + --fixed-block-start-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small)); + } + & :deep() { // Table head, body and footer tbody { @@ -337,24 +527,57 @@ export default defineComponent({ flex-direction: column; } + .files-list__selected { + padding-inline-end: 12px; + white-space: nowrap; + } + .files-list__table { display: block; + + &.files-list__table--with-thead-overlay { + // Hide the table header below the overlay + margin-block-start: calc(-1 * var(--row-height)); + } + + // Visually hide the table when there are no files + &--hidden { + visibility: hidden; + z-index: -1; + opacity: 0; + } } - .files-list__thead-overlay { - position: absolute; + .files-list__filters { + // Pinned on top when scrolling above table header + position: sticky; top: 0; - left: var(--row-height); // Save space for a row checkbox - right: 0; - z-index: 1000; + // ensure there is a background to hide the file list on scroll + background-color: var(--color-main-background); + z-index: 10; + // fixed the size + padding-inline: var(--row-height) var(--default-grid-baseline, 4px); + height: var(--fixed-block-start-position); + width: 100%; + } + + .files-list__thead-overlay { + // Pinned on top when scrolling + position: sticky; + top: var(--fixed-block-start-position); + // Save space for a row checkbox + margin-inline-start: var(--row-height); + // More than .files-list__thead + z-index: 20; display: flex; align-items: center; // Reuse row styles background-color: var(--color-main-background); - border-bottom: 1px solid var(--color-border); + border-block-end: 1px solid var(--color-border); height: var(--row-height); + flex: 0 0 var(--row-height); } .files-list__thead, @@ -363,7 +586,6 @@ export default defineComponent({ flex-direction: column; width: 100%; background-color: var(--color-main-background); - } // Table header @@ -371,12 +593,17 @@ export default defineComponent({ // Pinned on top when scrolling position: sticky; z-index: 10; - top: 0; + top: var(--fixed-block-start-position); } - // Table footer - .files-list__tfoot { - min-height: 300px; + // Empty content + .files-list__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; } tr { @@ -384,8 +611,7 @@ export default defineComponent({ display: flex; align-items: center; width: 100%; - user-select: none; - border-bottom: 1px solid var(--color-border); + border-block-end: 1px solid var(--color-border); box-sizing: border-box; user-select: none; height: var(--row-height); @@ -395,7 +621,7 @@ export default defineComponent({ display: flex; align-items: center; flex: 0 0 auto; - justify-content: left; + justify-content: start; width: var(--row-height); height: var(--row-height); margin: 0; @@ -417,8 +643,7 @@ export default defineComponent({ position: absolute; display: block; top: 0; - left: 0; - right: 0; + inset-inline: 0; bottom: 0; opacity: .1; z-index: -1; @@ -482,7 +707,7 @@ export default defineComponent({ width: var(--icon-preview-size); height: 100%; // Show same padding as the checkbox right padding for visual balance - margin-right: var(--checkbox-padding); + margin-inline-end: var(--checkbox-padding); color: var(--color-primary-element); // Icon is also clickable @@ -509,15 +734,31 @@ export default defineComponent({ } } - &-preview { + &-preview-container { + position: relative; // Needed for the blurshash to be positioned correctly overflow: hidden; width: var(--icon-preview-size); height: var(--icon-preview-size); border-radius: var(--border-radius); + } + + &-blurhash { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + height: 100%; + width: 100%; + object-fit: cover; + } + + &-preview { // Center and contain the preview object-fit: contain; object-position: center; + height: 100%; + width: 100%; + /* Preview not loaded animation effect */ &:not(.files-list__row-icon-preview--loaded) { background: var(--color-loading-dark); @@ -528,17 +769,17 @@ export default defineComponent({ &-favorite { position: absolute; top: 0px; - right: -10px; + inset-inline-end: -10px; } // File and folder overlay &-overlay { position: absolute; - max-height: calc(var(--icon-preview-size) * 0.5); - max-width: calc(var(--icon-preview-size) * 0.5); + max-height: calc(var(--icon-preview-size) * 0.6); + max-width: calc(var(--icon-preview-size) * 0.6); color: var(--color-primary-element-text); // better alignment with the folder icon - margin-top: 2px; + margin-block-start: 2px; // Improve icon contrast with a background for files &--file { @@ -556,24 +797,27 @@ export default defineComponent({ // Take as much space as possible flex: 1 1 auto; - a { + button.files-list__row-name-link { display: flex; align-items: center; + text-align: start; // Fill cell height and width width: 100%; height: 100%; // Necessary for flex grow to work min-width: 0; + margin: 0; + padding: 0; // Already added to the inner text, see rule below &:focus-visible { - outline: none; + outline: none !important; } // Keyboard indicator a11y &:focus .files-list__row-name-text { - outline: 2px solid var(--color-main-text) !important; - border-radius: 20px; + outline: var(--border-width-input-focused) solid var(--color-main-text) !important; + border-radius: var(--border-radius-element); } &:focus:not(:focus-visible) .files-list__row-name-text { outline: none !important; @@ -583,8 +827,8 @@ export default defineComponent({ .files-list__row-name-text { color: var(--color-main-text); // Make some space for the outline - padding: 5px 10px; - margin-left: -10px; + padding: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline)); + padding-inline-start: -10px; // Align two name and ext display: inline-flex; } @@ -603,7 +847,7 @@ export default defineComponent({ input { width: 100%; // Align with text, 0 - padding - border - margin-left: -8px; + margin-inline-start: -8px; padding: 2px 6px; border-width: 2px; @@ -634,44 +878,56 @@ export default defineComponent({ } .files-list__row-action--inline { - margin-right: 7px; + margin-inline-end: 7px; } + .files-list__row-mime, .files-list__row-mtime, .files-list__row-size { color: var(--color-text-maxcontrast); } + .files-list__row-size { - width: calc(var(--row-height) * 1.5); + width: calc(var(--row-height) * 2); // Right align content/text justify-content: flex-end; } .files-list__row-mtime { - width: calc(var(--row-height) * 2); + width: calc(var(--row-height) * 2.5); + } + + .files-list__row-mime { + width: calc(var(--row-height) * 3.5); } .files-list__row-column-custom { - width: calc(var(--row-height) * 2); + width: calc(var(--row-height) * 2.5); } } } + +@media screen and (max-width: 512px) { + .files-list :deep(.files-list__filters) { + // Reduce padding on mobile + padding-inline: var(--default-grid-baseline, 4px); + } +} + </style> <style lang="scss"> // Grid mode -tbody.files-list__tbody.files-list__tbody--grid { - --half-clickable-area: calc(var(--clickable-area) / 2); - --row-width: 160px; - // We use half of the clickable area as visual balance margin - --row-height: calc(var(--row-width) - var(--half-clickable-area)); - --icon-preview-size: calc(var(--row-width) - var(--clickable-area)); +.files-list--grid tbody.files-list__tbody { + --item-padding: 16px; + --icon-preview-size: 166px; + --name-height: var(--default-clickable-area); + --mtime-height: calc(var(--font-size-small) + var(--default-grid-baseline)); + --row-width: calc(var(--icon-preview-size) + var(--item-padding) * 2); + --row-height: calc(var(--icon-preview-size) + var(--name-height) + var(--mtime-height) + var(--item-padding) * 2); --checkbox-padding: 0px; - display: grid; grid-template-columns: repeat(auto-fill, var(--row-width)); - grid-gap: 15px; - row-gap: 15px; align-content: center; align-items: center; @@ -679,29 +935,44 @@ tbody.files-list__tbody.files-list__tbody--grid { justify-items: center; tr { + display: flex; + flex-direction: column; width: var(--row-width); - height: calc(var(--row-height) + var(--clickable-area)); + height: var(--row-height); border: none; - border-radius: var(--border-radius); + border-radius: var(--border-radius-large); + padding: var(--item-padding); } // Checkbox in the top left .files-list__row-checkbox { position: absolute; z-index: 9; - top: 0; - left: 0; + top: calc(var(--item-padding) / 2); + inset-inline-start: calc(var(--item-padding) / 2); overflow: hidden; - width: var(--clickable-area); - height: var(--clickable-area); - border-radius: var(--half-clickable-area); + --checkbox-container-size: 44px; + width: var(--checkbox-container-size); + height: var(--checkbox-container-size); + + // Add a background to the checkbox so we do not see the image through it. + .checkbox-radio-switch__content::after { + content: ''; + width: 16px; + height: 16px; + position: absolute; + inset-inline-start: 50%; + margin-inline-start: -8px; + z-index: -1; + background: var(--color-main-background); + } } // Star icon in the top right .files-list__row-icon-favorite { position: absolute; top: 0; - right: 0; + inset-inline-end: 0; display: flex; align-items: center; justify-content: center; @@ -710,38 +981,55 @@ tbody.files-list__tbody.files-list__tbody--grid { } .files-list__row-name { - display: grid; - justify-content: stretch; - width: 100%; - height: 100%; - grid-auto-rows: var(--row-height) var(--clickable-area); + display: flex; + flex-direction: column; + width: var(--icon-preview-size); + height: calc(var(--icon-preview-size) + var(--name-height)); + // Ensure that the name outline is visible. + overflow: visible; span.files-list__row-icon { - width: 100%; - height: 100%; - // Visual balance, we use half of the clickable area - // as a margin around the preview - padding-top: var(--half-clickable-area); - } - - a.files-list__row-name-link { - // Minus action menu - width: calc(100% - var(--clickable-area)); - height: var(--clickable-area); + width: var(--icon-preview-size); + height: var(--icon-preview-size); } .files-list__row-name-text { margin: 0; - padding-right: 0; + // Ensure that the outline is not too close to the text. + margin-inline-start: -4px; + padding: 0px 4px; } } + .files-list__row-mtime { + width: var(--icon-preview-size); + height: var(--mtime-height); + font-size: var(--font-size-small); + } + .files-list__row-actions { position: absolute; - right: 0; - bottom: 0; + inset-inline-end: calc(var(--clickable-area) / 4); + inset-block-end: calc(var(--mtime-height) / 2); width: var(--clickable-area); height: var(--clickable-area); } } + +@media screen and (max-width: 768px) { + // there is no mtime + .files-list--grid tbody.files-list__tbody { + --mtime-height: 0px; + + // so we move the action to the name + .files-list__row-actions { + inset-block-end: var(--item-padding); + } + + // and we need to keep space on the name for the actions + .files-list__row-name-text { + padding-inline-end: var(--clickable-area) !important; + } + } +} </style> diff --git a/apps/files/src/components/FilesNavigationItem.vue b/apps/files/src/components/FilesNavigationItem.vue new file mode 100644 index 00000000000..c29bc00c67f --- /dev/null +++ b/apps/files/src/components/FilesNavigationItem.vue @@ -0,0 +1,182 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcAppNavigationItem v-for="view in currentViews" + :key="view.id" + class="files-navigation__item" + allow-collapse + :loading="view.loading" + :data-cy-files-navigation-item="view.id" + :exact="useExactRouteMatching(view)" + :icon="view.iconClass" + :name="view.name" + :open="isExpanded(view)" + :pinned="view.sticky" + :to="generateToNavigation(view)" + :style="style" + @update:open="(open) => onOpen(open, view)"> + <template v-if="view.icon" #icon> + <NcIconSvgWrapper :svg="view.icon" /> + </template> + + <!-- Hack to force the collapse icon to be displayed --> + <li v-if="view.loadChildViews && !view.loaded" style="display: none" /> + + <!-- Recursively nest child views --> + <FilesNavigationItem v-if="hasChildViews(view)" + :parent="view" + :level="level + 1" + :views="filterView(views, parent.id)" /> + </NcAppNavigationItem> + </Fragment> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { View } from '@nextcloud/files' + +import { defineComponent } from 'vue' +import { Fragment } from 'vue-frag' + +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +import { useNavigation } from '../composables/useNavigation.js' +import { useViewConfigStore } from '../store/viewConfig.js' + +const maxLevel = 7 // Limit nesting to not exceed max call stack size + +export default defineComponent({ + name: 'FilesNavigationItem', + + components: { + Fragment, + NcAppNavigationItem, + NcIconSvgWrapper, + }, + + props: { + parent: { + type: Object as PropType<View>, + default: () => ({}), + }, + level: { + type: Number, + default: 0, + }, + views: { + type: Object as PropType<Record<string, View[]>>, + default: () => ({}), + }, + }, + + setup() { + const { currentView } = useNavigation() + const viewConfigStore = useViewConfigStore() + return { + currentView, + viewConfigStore, + } + }, + + computed: { + currentViews(): View[] { + if (this.level >= maxLevel) { // Filter for all remaining decendants beyond the max level + return (Object.values(this.views).reduce((acc, views) => [...acc, ...views], []) as View[]) + .filter(view => view.params?.dir.startsWith(this.parent.params?.dir)) + } + return this.filterVisible(this.views[this.parent.id] ?? []) + }, + + style() { + if (this.level === 0 || this.level === 1 || this.level > maxLevel) { // Left-align deepest entry with center of app navigation, do not add any more visual indentation after this level + return null + } + return { + 'padding-left': '16px', + } + }, + }, + + methods: { + filterVisible(views: View[]) { + return views.filter(({ id, hidden }) => id === this.currentView?.id || hidden !== true) + }, + + hasChildViews(view: View): boolean { + if (this.level >= maxLevel) { + return false + } + return this.filterVisible(this.views[view.id] ?? []).length > 0 + }, + + /** + * Only use exact route matching on routes with child views + * Because if a view does not have children (like the files view) then multiple routes might be matched for it + * Like for the 'files' view this does not work because of optional 'fileid' param so /files and /files/1234 are both in the 'files' view + * @param view The view to check + */ + useExactRouteMatching(view: View): boolean { + return this.hasChildViews(view) + }, + + /** + * Generate the route to a view + * @param view View to generate "to" navigation for + */ + generateToNavigation(view: View) { + if (view.params) { + const { dir } = view.params + return { name: 'filelist', params: { ...view.params }, query: { dir } } + } + return { name: 'filelist', params: { view: view.id } } + }, + + /** + * Check if a view is expanded by user config + * or fallback to the default value. + * @param view View to check if expanded + */ + isExpanded(view: View): boolean { + return typeof this.viewConfigStore.getConfig(view.id)?.expanded === 'boolean' + ? this.viewConfigStore.getConfig(view.id).expanded === true + : view.expanded === true + }, + + /** + * Expand/collapse a a view with children and permanently + * save this setting in the server. + * @param open True if open + * @param view View + */ + async onOpen(open: boolean, view: View) { + // Invert state + const isExpanded = this.isExpanded(view) + // Update the view expanded state, might not be necessary + view.expanded = !isExpanded + this.viewConfigStore.update(view.id, 'expanded', !isExpanded) + if (open && view.loadChildViews) { + await view.loadChildViews(view) + } + }, + + /** + * Return the view map with the specified view id removed + * + * @param viewMap Map of views + * @param id View id + */ + filterView(viewMap: Record<string, View[]>, id: string): Record<string, View[]> { + return Object.fromEntries( + Object.entries(viewMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([viewId, _views]) => viewId !== id), + ) + }, + }, +}) +</script> diff --git a/apps/files/src/components/FilesNavigationSearch.vue b/apps/files/src/components/FilesNavigationSearch.vue new file mode 100644 index 00000000000..0890dffcb39 --- /dev/null +++ b/apps/files/src/components/FilesNavigationSearch.vue @@ -0,0 +1,86 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import { mdiMagnify, mdiSearchWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { onBeforeNavigation } from '../composables/useBeforeNavigation.ts' +import { useNavigation } from '../composables/useNavigation.ts' +import { useSearchStore } from '../store/search.ts' +import { VIEW_ID } from '../views/search.ts' + +const { currentView } = useNavigation(true) +const searchStore = useSearchStore() + +/** + * When the route is changed from search view to something different + * we need to clear the search box. + */ +onBeforeNavigation((to, from, next) => { + if (to.params.view !== VIEW_ID && from.params.view === VIEW_ID) { + // we are leaving the search view so unset the query + searchStore.query = '' + searchStore.scope = 'filter' + } else if (to.params.view === VIEW_ID && from.params.view === VIEW_ID) { + // fix the query if the user refreshed the view + if (searchStore.query && !to.query.query) { + // @ts-expect-error This is a weird issue with vue-router v4 and will be fixed in v5 (vue 3) + return next({ + ...to, + query: { + ...to.query, + query: searchStore.query, + }, + }) + } + } + next() +}) + +/** + * Are we currently on the search view. + * Needed to disable the action menu (we cannot change the search mode there) + */ +const isSearchView = computed(() => currentView.value.id === VIEW_ID) + +/** + * Different searchbox label depending if filtering or searching + */ +const searchLabel = computed(() => { + if (searchStore.scope === 'globally') { + return t('files', 'Search everywhere …') + } + return t('files', 'Search here …') +}) +</script> + +<template> + <NcAppNavigationSearch v-model="searchStore.query" :label="searchLabel"> + <template #actions> + <NcActions :aria-label="t('files', 'Search scope options')" :disabled="isSearchView"> + <template #icon> + <NcIconSvgWrapper :path="searchStore.scope === 'globally' ? mdiSearchWeb : mdiMagnify" /> + </template> + <NcActionButton close-after-click @click="searchStore.scope = 'filter'"> + <template #icon> + <NcIconSvgWrapper :path="mdiMagnify" /> + </template> + {{ t('files', 'Search here') }} + </NcActionButton> + <NcActionButton close-after-click @click="searchStore.scope = 'globally'"> + <template #icon> + <NcIconSvgWrapper :path="mdiSearchWeb" /> + </template> + {{ t('files', 'Search everywhere') }} + </NcActionButton> + </NcActions> + </template> + </NcAppNavigationSearch> +</template> diff --git a/apps/files/src/components/LegacyView.vue b/apps/files/src/components/LegacyView.vue index 4a50ed558f0..b5a792d9029 100644 --- a/apps/files/src/components/LegacyView.vue +++ b/apps/files/src/components/LegacyView.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 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/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> @@ -50,10 +33,8 @@ export default { }, methods: { setFileInfo(fileInfo) { - this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo)) + this.component.setFileInfo(fileInfo) }, }, } </script> -<style> -</style> diff --git a/apps/files/src/components/NavigationQuota.vue b/apps/files/src/components/NavigationQuota.vue index 943d61cf0f5..46c8e5c9af4 100644 --- a/apps/files/src/components/NavigationQuota.vue +++ b/apps/files/src/components/NavigationQuota.vue @@ -1,6 +1,10 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> <template> <NcAppNavigationItem v-if="storageStats" - :aria-label="t('files', 'Storage informations')" + :aria-description="t('files', 'Storage information')" :class="{ 'app-navigation-entry__settings-quota--not-unlimited': storageStats.quota >= 0}" :loading="loadingStorageStats" :name="storageStatsTitle" @@ -13,6 +17,7 @@ <!-- Progress bar --> <NcProgressBar v-if="storageStats.quota >= 0" slot="extra" + :aria-label="t('files', 'Storage quota')" :error="storageStats.relative > 80" :value="Math.min(storageStats.relative, 100)" /> </NcAppNavigationItem> @@ -28,11 +33,11 @@ import { subscribe } from '@nextcloud/event-bus' import { translate } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import ChartPie from 'vue-material-design-icons/ChartPie.vue' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' +import ChartPie from 'vue-material-design-icons/ChartPieOutline.vue' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' -import logger from '../logger.js' +import logger from '../logger.ts' export default { name: 'NavigationQuota', @@ -53,7 +58,7 @@ export default { computed: { storageStatsTitle() { const usedQuotaByte = formatFileSize(this.storageStats?.used, false, false) - const quotaByte = formatFileSize(this.storageStats?.quota, false, false) + const quotaByte = formatFileSize(this.storageStats?.total, false, false) // If no quota set if (this.storageStats?.quota < 0) { @@ -88,8 +93,17 @@ export default { }, mounted() { - // Warn the user if the available storage is 0 on page load - if (this.storageStats?.free <= 0) { + // If the user has a quota set, warn if the available account storage is <=0 + // + // NOTE: This doesn't catch situations where actual *server* + // disk (non-quota) space is low, but those should probably + // be handled differently anyway since a regular user can't + // can't do much about them (If we did want to indicate server disk + // space matters to users, we'd probably want to use a warning + // specific to that situation anyhow. So this covers warning covers + // our primary day-to-day concern (individual account quota usage). + // + if (this.storageStats?.quota > 0 && this.storageStats?.free === 0) { this.showStorageFullWarning() } }, @@ -108,7 +122,7 @@ export default { * Update the storage stats * Throttled at max 1 refresh per minute * - * @param {Event} [event = null] if user interaction + * @param {Event} [event] if user interaction */ async updateStorageStats(event = null) { if (this.loadingStorageStats) { @@ -122,8 +136,9 @@ export default { throw new Error('Invalid storage stats') } - // Warn the user if the available storage changed from > 0 to 0 - if (this.storageStats?.free > 0 && response.data.data?.free <= 0) { + // Warn the user if the available account storage changed from > 0 to 0 + // (unless only because quota was intentionally set to 0 by admin in the interim) + if (this.storageStats?.free > 0 && response.data.data?.free === 0 && response.data.data?.quota > 0) { this.showStorageFullWarning() } @@ -152,15 +167,18 @@ export default { // User storage stats display .app-navigation-entry__settings-quota { // Align title with progress and icon - &--not-unlimited::v-deep .app-navigation-entry__name { - margin-top: -6px; + --app-navigation-quota-margin: calc((var(--default-clickable-area) - 24px) / 2); // 20px icon size and 4px progress bar + + &--not-unlimited :deep(.app-navigation-entry__name) { + line-height: 1; + margin-top: var(--app-navigation-quota-margin); } progress { position: absolute; - bottom: 12px; - margin-left: 44px; - width: calc(100% - 44px - 22px); + bottom: var(--app-navigation-quota-margin); + margin-inline-start: var(--default-clickable-area); + width: calc(100% - (1.5 * var(--default-clickable-area))); } } </style> diff --git a/apps/files/src/components/NewNodeDialog.vue b/apps/files/src/components/NewNodeDialog.vue new file mode 100644 index 00000000000..ca10935940d --- /dev/null +++ b/apps/files/src/components/NewNodeDialog.vue @@ -0,0 +1,168 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog data-cy-files-new-node-dialog + :name="name" + :open="open" + close-on-click-outside + out-transition + @update:open="emit('close', null)"> + <template #actions> + <NcButton data-cy-files-new-node-dialog-submit + type="primary" + :disabled="validity !== ''" + @click="submit"> + {{ t('files', 'Create') }} + </NcButton> + </template> + <form ref="formElement" + class="new-node-dialog__form" + @submit.prevent="emit('close', localDefaultName)"> + <NcTextField ref="nameInput" + data-cy-files-new-node-dialog-input + :error="validity !== ''" + :helper-text="validity" + :label="label" + :value.sync="localDefaultName" /> + + <!-- Hidden file warning --> + <NcNoteCard v-if="isHiddenFileName" + type="warning" + :text="t('files', 'Files starting with a dot are hidden by default')" /> + </form> + </NcDialog> +</template> + +<script setup lang="ts"> +import type { ComponentPublicInstance, PropType } from 'vue' +import { getUniqueName } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import { extname } from 'path' +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' +import { getFilenameValidity } from '../utils/filenameValidity.ts' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +const props = defineProps({ + /** + * The name to be used by default + */ + defaultName: { + type: String, + default: t('files', 'New folder'), + }, + /** + * Other files that are in the current directory + */ + otherNames: { + type: Array as PropType<string[]>, + default: () => [], + }, + /** + * Open state of the dialog + */ + open: { + type: Boolean, + default: true, + }, + /** + * Dialog name + */ + name: { + type: String, + default: t('files', 'Create new folder'), + }, + /** + * Input label + */ + label: { + type: String, + default: t('files', 'Folder name'), + }, +}) + +const emit = defineEmits<{ + (event: 'close', name: string | null): void +}>() + +const localDefaultName = ref<string>(props.defaultName) +const nameInput = ref<ComponentPublicInstance>() +const formElement = ref<HTMLFormElement>() +const validity = ref('') + +const isHiddenFileName = computed(() => { + // Check if the name starts with a dot, which indicates a hidden file + return localDefaultName.value.trim().startsWith('.') +}) + +/** + * Focus the filename input field + */ +function focusInput() { + nextTick(() => { + // get the input element + const input = nameInput.value?.$el.querySelector('input') + if (!props.open || !input) { + return + } + + // length of the basename + const length = localDefaultName.value.length - extname(localDefaultName.value).length + // focus the input + input.focus() + // and set the selection to the basename (name without extension) + input.setSelectionRange(0, length) + }) +} + +/** + * Trigger submit on the form + */ +function submit() { + formElement.value?.requestSubmit() +} + +// Reset local name on props change +watch(() => [props.defaultName, props.otherNames], () => { + localDefaultName.value = getUniqueName(props.defaultName, props.otherNames).trim() +}) + +// Validate the local name +watchEffect(() => { + if (props.otherNames.includes(localDefaultName.value.trim())) { + validity.value = t('files', 'This name is already in use.') + } else { + validity.value = getFilenameValidity(localDefaultName.value.trim()) + } + const input = nameInput.value?.$el.querySelector('input') + if (input) { + input.setCustomValidity(validity.value) + input.reportValidity() + } +}) + +// Ensure the input is focussed even if the dialog is already mounted but not open +watch(() => props.open, () => { + nextTick(() => { + focusInput() + }) +}) + +onMounted(() => { + // on mounted lets use the unique name + localDefaultName.value = getUniqueName(localDefaultName.value, props.otherNames).trim() + nextTick(() => focusInput()) +}) +</script> + +<style scoped> +.new-node-dialog__form { + /* Ensure the dialog does not jump when there is a validity error */ + min-height: calc(2 * var(--default-clickable-area)); +} +</style> diff --git a/apps/files/src/components/PersonalSettings.vue b/apps/files/src/components/PersonalSettings.vue index 5f5dc31eafb..b076b0c1e3d 100644 --- a/apps/files/src/components/PersonalSettings.vue +++ b/apps/files/src/components/PersonalSettings.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @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/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div id="files-personal-settings" class="section"> diff --git a/apps/files/src/components/Setting.vue b/apps/files/src/components/Setting.vue index cb22dc3e477..7a9ffb137a2 100644 --- a/apps/files/src/components/Setting.vue +++ b/apps/files/src/components/Setting.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2020 Gary Kim <gary@garykim.dev> - - - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div /> diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue index 78c050048f1..d86e5da9d20 100644 --- a/apps/files/src/components/SidebarTab.vue +++ b/apps/files/src/components/SidebarTab.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 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/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcAppSidebarTab :id="id" ref="tab" @@ -38,8 +21,8 @@ </template> <script> -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' export default { name: 'SidebarTab', @@ -65,7 +48,7 @@ export default { }, icon: { type: String, - required: false, + default: '', }, /** diff --git a/apps/files/src/components/TemplateFiller.vue b/apps/files/src/components/TemplateFiller.vue new file mode 100644 index 00000000000..3f1db8dfd58 --- /dev/null +++ b/apps/files/src/components/TemplateFiller.vue @@ -0,0 +1,122 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcModal label-id="template-field-modal__label"> + <div class="template-field-modal__content"> + <form> + <h3 id="template-field-modal__label"> + {{ t('files', 'Fill template fields') }} + </h3> + + <div v-for="field in fields" :key="field.index"> + <component :is="getFieldComponent(field.type)" + v-if="fieldHasLabel(field)" + :field="field" + @input="trackInput" /> + </div> + </form> + </div> + + <div class="template-field-modal__buttons"> + <NcLoadingIcon v-if="loading" :name="t('files', 'Submitting fields …')" /> + <NcButton aria-label="Submit button" + type="primary" + @click="submit"> + {{ t('files', 'Submit') }} + </NcButton> + </div> + </NcModal> +</template> + +<script> +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcModal from '@nextcloud/vue/components/NcModal' +import TemplateRichTextField from './TemplateFiller/TemplateRichTextField.vue' +import TemplateCheckboxField from './TemplateFiller/TemplateCheckboxField.vue' + +export default defineComponent({ + name: 'TemplateFiller', + + components: { + NcModal, + NcButton, + NcLoadingIcon, + TemplateRichTextField, + TemplateCheckboxField, + }, + + props: { + fields: { + type: Array, + default: () => [], + }, + onSubmit: { + type: Function, + default: async () => {}, + }, + }, + + data() { + return { + localFields: {}, + loading: false, + } + }, + + methods: { + t, + trackInput({ index, property, value }) { + if (!this.localFields[index]) { + this.localFields[index] = {} + } + + this.localFields[index][property] = value + }, + getFieldComponent(fieldType) { + const fieldComponentType = fieldType.split('-') + .map((str) => { + return str.charAt(0).toUpperCase() + str.slice(1) + }) + .join('') + + return `Template${fieldComponentType}Field` + }, + fieldHasLabel(field) { + return field.name || field.alias + }, + async submit() { + this.loading = true + + await this.onSubmit(this.localFields) + + this.$emit('close') + }, + }, +}) +</script> + +<style lang="scss" scoped> +$modal-margin: calc(var(--default-grid-baseline) * 4); + +.template-field-modal__content { + padding: $modal-margin; + + h3 { + text-align: center; + } +} + +.template-field-modal__buttons { + display: flex; + justify-content: flex-end; + gap: var(--default-grid-baseline); + margin: $modal-margin; + margin-top: 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue new file mode 100644 index 00000000000..18536171bd2 --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateCheckboxField.vue @@ -0,0 +1,68 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__checkbox"> + <NcCheckboxRadioSwitch :id="fieldId" + :checked.sync="value" + type="switch" + @update:checked="input"> + {{ fieldLabel }} + </NcCheckboxRadioSwitch> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +export default defineComponent({ + name: 'TemplateCheckboxField', + + components: { + NcCheckboxRadioSwitch, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: this.field.checked ?? false, + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return label.charAt(0).toUpperCase() + label.slice(1) + }, + fieldId() { + return 'checkbox-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'checked', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__checkbox { + margin: 20px 0; +} +</style> diff --git a/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue new file mode 100644 index 00000000000..f49819f7e7c --- /dev/null +++ b/apps/files/src/components/TemplateFiller/TemplateRichTextField.vue @@ -0,0 +1,77 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="template-field__text"> + <label :for="fieldId"> + {{ fieldLabel }} + </label> + + <NcTextField :id="fieldId" + type="text" + :value.sync="value" + :label="fieldLabel" + :label-outside="true" + :placeholder="field.content" + @input="input" /> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default defineComponent({ + name: 'TemplateRichTextField', + + components: { + NcTextField, + }, + + props: { + field: { + type: Object, + default: () => {}, + }, + }, + + data() { + return { + value: '', + } + }, + + computed: { + fieldLabel() { + const label = this.field.name || this.field.alias + + return (label.charAt(0).toUpperCase() + label.slice(1)) + }, + fieldId() { + return 'text-field' + this.field.index + }, + }, + + methods: { + input() { + this.$emit('input', { + index: this.field.index, + property: 'content', + value: this.value, + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +.template-field__text { + margin: 20px 0; + + label { + font-weight: bold; + } +} +</style> diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue index 53195d028c6..7927948d3af 100644 --- a/apps/files/src/components/TemplatePreview.vue +++ b/apps/files/src/components/TemplatePreview.vue @@ -1,35 +1,19 @@ <!-- - - @copyright Copyright (c) 2020 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/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="template-picker__item"> <input :id="id" + ref="input" :checked="checked" type="radio" class="radio" name="template-picker" @change="onCheck"> - <label :for="id" class="template-picker__label"> + <label :for="id" class="template-picker__label" @click="onClick"> <div class="template-picker__preview" :class="failedPreview ? 'template-picker__preview--failed' : ''"> <img class="template-picker__image" @@ -47,9 +31,9 @@ </template> <script> +import { encodePath } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' -import { encodeFilePath } from '../utils/fileUtils.ts' -import { getToken, isPublic } from '../utils/davUtils.js' +import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public' // preview width generation const previewWidth = 256 @@ -123,8 +107,8 @@ export default { return this.previewUrl } // TODO: find a nicer standard way of doing this? - if (isPublic()) { - return generateUrl(`/apps/files_sharing/publicpreview/${getToken()}?fileId=${this.fileid}&file=${encodeFilePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) + if (isPublicShare()) { + return generateUrl(`/apps/files_sharing/publicpreview/${getSharingToken()}?fileId=${this.fileid}&file=${encodePath(this.filename)}&x=${previewWidth}&y=${previewWidth}&a=1`) } return generateUrl(`/core/preview?fileId=${this.fileid}&x=${previewWidth}&y=${previewWidth}&a=1`) }, @@ -141,6 +125,14 @@ export default { onFailure() { this.failedPreview = true }, + focus() { + this.$refs.input?.focus() + }, + onClick() { + if (this.checked) { + this.$emit('confirm-click', this.fileid) + } + }, }, } </script> @@ -209,12 +201,9 @@ export default { } &__title { - overflow: hidden; // also count preview border - max-width: calc(var(--width) + 2*2px); + max-width: calc(var(--width) + 2 * 2px); padding: var(--margin); - white-space: nowrap; - text-overflow: ellipsis; } } diff --git a/apps/files/src/components/TransferOwnershipDialogue.vue b/apps/files/src/components/TransferOwnershipDialogue.vue index e7cecb5224c..3d668da8144 100644 --- a/apps/files/src/components/TransferOwnershipDialogue.vue +++ b/apps/files/src/components/TransferOwnershipDialogue.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @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/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div> @@ -25,7 +9,7 @@ <form @submit.prevent="submit"> <p class="transfer-select-row"> <span>{{ readableDirectory }}</span> - <NcButton v-if="directory === undefined" + <NcButton v-if="directory === undefined" class="transfer-select-row__choose_button" @click.prevent="start"> {{ t('files', 'Choose file or folder to transfer') }} @@ -34,18 +18,16 @@ {{ t('files', 'Change') }} </NcButton> </p> - <p class="new-owner-row"> + <p class="new-owner"> <label for="targetUser"> <span>{{ t('files', 'New owner') }}</span> </label> - <NcSelect input-id="targetUser" - v-model="selectedUser" + <NcSelect v-model="selectedUser" + input-id="targetUser" :options="formatedUserSuggestions" :multiple="false" :loading="loadingUsers" - label="displayName" :user-select="true" - class="middle-align" @search="findUserDebounced" /> </p> <p> @@ -64,11 +46,11 @@ import axios from '@nextcloud/axios' import debounce from 'debounce' import { generateOcsUrl } from '@nextcloud/router' import { getFilePickerBuilder, showSuccess, showError } from '@nextcloud/dialogs' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import Vue from 'vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' -import logger from '../logger.js' +import logger from '../logger.ts' const picker = getFilePickerBuilder(t('files', 'Choose a file or folder to transfer')) .setMultiSelect(false) @@ -106,6 +88,7 @@ export default { user: user.uid, displayName: user.displayName, icon: 'icon-user', + subname: user.shareWithDisplayNameUnique, } }) }, @@ -172,6 +155,7 @@ export default { Vue.set(this.userSuggestions, user.value.shareWith, { uid: user.value.shareWith, displayName: user.label, + shareWithDisplayNameUnique: user.shareWithDisplayNameUnique, }) }) } catch (error) { @@ -219,16 +203,15 @@ export default { </script> <style scoped lang="scss"> -.middle-align { - vertical-align: middle; -} p { margin-top: 12px; margin-bottom: 12px; } -.new-owner-row { + +.new-owner { display: flex; - flex-wrap: wrap; + flex-direction: column; + max-width: 400px; label { display: flex; @@ -236,18 +219,14 @@ p { margin-bottom: calc(var(--default-grid-baseline) * 2); span { - margin-right: 8px; + margin-inline-end: 8px; } } - - .multiselect { - flex-grow: 1; - max-width: 280px; - } } + .transfer-select-row { span { - margin-right: 8px; + margin-inline-end: 8px; } &__choose_button { diff --git a/apps/files/src/components/VirtualList.vue b/apps/files/src/components/VirtualList.vue index 77454772f55..4746fedf863 100644 --- a/apps/files/src/components/VirtualList.vue +++ b/apps/files/src/components/VirtualList.vue @@ -1,15 +1,37 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> <template> - <div class="files-list" data-cy-files-list> + <div class="files-list" + :class="{ 'files-list--grid': gridMode }" + data-cy-files-list + @scroll.passive="onScroll"> + <!-- Header --> + <div ref="before" class="files-list__before"> + <slot name="before" /> + </div> + + <div ref="filters" class="files-list__filters"> + <slot name="filters" /> + </div> + <div v-if="!!$scopedSlots['header-overlay']" class="files-list__thead-overlay"> <slot name="header-overlay" /> </div> - <!-- Header --> - <div ref="before" class="files-list__before"> - <slot name="before" /> + <div v-if="dataSources.length === 0" + class="files-list__empty"> + <slot name="empty" /> </div> - <table class="files-list__table"> + <table :aria-hidden="dataSources.length === 0" + :inert="dataSources.length === 0" + class="files-list__table" + :class="{ + 'files-list__table--with-thead-overlay': !!$scopedSlots['header-overlay'], + 'files-list__table--hidden': dataSources.length === 0, + }"> <!-- Accessibility table caption for screen readers --> <caption v-if="caption" class="hidden-visually"> {{ caption }} @@ -23,7 +45,6 @@ <!-- Body --> <tbody :style="tbodyStyle" class="files-list__tbody" - :class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'" data-cy-files-list-tbody> <component :is="dataComponent" v-for="({key, item}, i) in renderedItems" @@ -34,7 +55,7 @@ </tbody> <!-- Footer --> - <tfoot v-show="isReady" + <tfoot ref="footer" class="files-list__tfoot" data-cy-files-list-tfoot> <slot name="footer" /> @@ -47,21 +68,22 @@ import type { File, Folder, Node } from '@nextcloud/files' import type { PropType } from 'vue' -import { debounce } from 'debounce' -import Vue from 'vue' +import { defineComponent } from 'vue' +import debounce from 'debounce' -import filesListWidthMixin from '../mixins/filesListWidth.ts' -import logger from '../logger.js' +import { useFileListWidth } from '../composables/useFileListWidth.ts' +import logger from '../logger.ts' interface RecycledPoolItem { key: string, item: Node, } -export default Vue.extend({ - name: 'VirtualList', +type DataSource = File | Folder +type DataSourceKey = keyof DataSource - mixins: [filesListWidthMixin], +export default defineComponent({ + name: 'VirtualList', props: { dataComponent: { @@ -69,11 +91,11 @@ export default Vue.extend({ required: true, }, dataKey: { - type: String, + type: String as PropType<DataSourceKey>, required: true, }, dataSources: { - type: Array as PropType<(File | Folder)[]>, + type: Array as PropType<DataSource[]>, required: true, }, extraProps: { @@ -89,7 +111,7 @@ export default Vue.extend({ default: false, }, /** - * Visually hidden caption for the table accesibility + * Visually hidden caption for the table accessibility */ caption: { type: String, @@ -97,10 +119,19 @@ export default Vue.extend({ }, }, + setup() { + const fileListWidth = useFileListWidth() + + return { + fileListWidth, + } + }, + data() { return { index: this.scrollToIndex, beforeHeight: 0, + footerHeight: 0, headerHeight: 0, tableHeight: 0, resizeObserver: null as ResizeObserver | null, @@ -116,35 +147,66 @@ export default Vue.extend({ // Items to render before and after the visible area bufferItems() { if (this.gridMode) { + // 1 row before and after in grid mode return this.columnCount } + // 3 rows before and after return 3 }, itemHeight() { // Align with css in FilesListVirtual - // 138px + 44px (name) + 15px (grid gap) - return this.gridMode ? (138 + 44 + 15) : 55 + // 166px + 32px (name) + 16px (mtime) + 16px (padding top and bottom) + return this.gridMode ? (166 + 32 + 16 + 16 + 16) : 44 }, + // Grid mode only itemWidth() { - // 160px + 15px grid gap - return 160 + 15 + // 166px + 16px x 2 (padding left and right) + return 166 + 16 + 16 }, - rowCount() { - return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1 + /** + * The number of rows currently (fully!) visible + */ + visibleRows(): number { + return Math.floor((this.tableHeight - this.headerHeight) / this.itemHeight) }, - columnCount() { + + /** + * Number of rows that will be rendered. + * This includes only visible + buffer rows. + */ + rowCount(): number { + return this.visibleRows + (this.bufferItems / this.columnCount) * 2 + 1 + }, + + /** + * Number of columns. + * 1 for list view otherwise depending on the file list width. + */ + columnCount(): number { if (!this.gridMode) { return 1 } - return Math.floor(this.filesListWidth / this.itemWidth) + return Math.floor(this.fileListWidth / this.itemWidth) }, + /** + * Index of the first item to be rendered + * The index can be any file, not just the first one + * But the start index is the first item to be rendered, + * which needs to align with the column count + */ startIndex() { - return Math.max(0, this.index - this.bufferItems) + const firstColumnIndex = this.index - (this.index % this.columnCount) + return Math.max(0, firstColumnIndex - this.bufferItems) }, + + /** + * Number of items to be rendered at the same time + * For list view this is the same as `rowCount`, for grid view this is `rowCount` * `columnCount` + */ shownItems() { // If in grid mode, we need to multiply the number of rows by the number of columns if (this.gridMode) { @@ -153,6 +215,7 @@ export default Vue.extend({ return this.rowCount }, + renderedItems(): RecycledPoolItem[] { if (!this.isReady) { return [] @@ -181,13 +244,23 @@ export default Vue.extend({ }) }, + /** + * The total number of rows that are available + */ + totalRowCount() { + return Math.ceil(this.dataSources.length / this.columnCount) + }, + tbodyStyle() { - const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length - const lastIndex = this.dataSources.length - this.startIndex - this.shownItems - const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount) + // The number of (virtual) rows above the currently rendered ones. + // start index is aligned so this should always be an integer + const rowsAbove = Math.round(this.startIndex / this.columnCount) + // The number of (virtual) rows below the currently rendered ones. + const rowsBelow = Math.max(0, this.totalRowCount - rowsAbove - this.rowCount) + return { - paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`, - paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`, + paddingBlock: `${rowsAbove * this.itemHeight}px ${rowsBelow * this.itemHeight}px`, + minHeight: `${this.totalRowCount * this.itemHeight}px`, } }, }, @@ -195,11 +268,17 @@ export default Vue.extend({ scrollToIndex(index) { this.scrollTo(index) }, + + totalRowCount() { + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }, + columnCount(columnCount, oldColumnCount) { if (oldColumnCount === 0) { - // We're initializing, the scroll position - // is handled on mounted - console.debug('VirtualList: columnCount is 0, skipping scroll') + // We're initializing, the scroll position is handled on mounted + logger.debug('VirtualList: columnCount is 0, skipping scroll') return } // If the column count changes in grid view, @@ -209,30 +288,28 @@ export default Vue.extend({ }, mounted() { - const before = this.$refs?.before as HTMLElement - const root = this.$el as HTMLElement - const thead = this.$refs?.thead as HTMLElement + this.$_recycledPool = {} as Record<string, DataSource[DataSourceKey]> this.resizeObserver = new ResizeObserver(debounce(() => { - this.beforeHeight = before?.clientHeight ?? 0 - this.headerHeight = thead?.clientHeight ?? 0 - this.tableHeight = root?.clientHeight ?? 0 + this.updateHeightVariables() logger.debug('VirtualList: resizeObserver updated') this.onScroll() - }, 100, false)) - - this.resizeObserver.observe(before) - this.resizeObserver.observe(root) - this.resizeObserver.observe(thead) - - if (this.scrollToIndex) { - this.scrollTo(this.scrollToIndex) - } - - // Adding scroll listener AFTER the initial scroll to index - this.$el.addEventListener('scroll', this.onScroll, { passive: true }) - - this.$_recycledPool = {} as Record<string, any> + }, 100)) + this.resizeObserver.observe(this.$el) + this.resizeObserver.observe(this.$refs.before as HTMLElement) + this.resizeObserver.observe(this.$refs.filters as HTMLElement) + this.resizeObserver.observe(this.$refs.footer as HTMLElement) + + this.$nextTick(() => { + // Make sure height values are initialized + this.updateHeightVariables() + // If we need to scroll to an index we do so in the next tick. + // This is needed to apply updates from the initialization of the height variables + // which will update the tbody styles until next tick. + if (this.scrollToIndex) { + this.scrollTo(this.scrollToIndex) + } + }) }, beforeDestroy() { @@ -243,23 +320,105 @@ export default Vue.extend({ methods: { scrollTo(index: number) { + if (!this.$el || this.index === index) { + return + } + + // Check if the content is smaller (not equal! keep the footer in mind) than the viewport + // meaning there is no scrollbar + if (this.totalRowCount < this.visibleRows) { + logger.debug('VirtualList: Skip scrolling, nothing to scroll', { + index, + totalRows: this.totalRowCount, + visibleRows: this.visibleRows, + }) + return + } + + // We can not scroll further as the last page of rows + // For the grid view we also need to account for all columns in that row (columnCount - 1) + const clampedIndex = (this.totalRowCount - this.visibleRows) * this.columnCount + (this.columnCount - 1) + // The scroll position + let scrollTop = this.indexToScrollPos(Math.min(index, clampedIndex)) + + // First we need to update the internal index for rendering. + // This will cause the <tbody> element to be resized allowing us to set the correct scroll position. this.index = index - // Scroll to one row and a half before the index - const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight - logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount }) - this.$el.scrollTop = scrollTop + + // If this is not the first row we can add a half row from above. + // This is to help users understand the table is scrolled and not items did not just disappear. + // But we also can only add a half row if we have enough rows below to scroll (visual rows / end of scrollable area) + if (index >= this.columnCount && index <= clampedIndex) { + scrollTop -= (this.itemHeight / 2) + // As we render one half row more we also need to adjust the internal index + this.index = index - this.columnCount + } else if (index > clampedIndex) { + // If we are on the last page we cannot scroll any further + // but we can at least scroll the footer into view + if (index <= (clampedIndex + this.columnCount)) { + // We only show have of the footer for the first of the last page + // To still show the previous row partly. Same reasoning as above: + // help the user understand that the table is scrolled not "magically trimmed" + scrollTop += this.footerHeight / 2 + } else { + // We reached the very end of the files list and we are focussing not the first visible row + // so all we now can do is scroll to the end (footer) + scrollTop += this.footerHeight + } + } + + // Now we need to wait for the <tbody> element to get resized so we can correctly apply the scrollTop position + this.$nextTick(() => { + this.$el.scrollTop = scrollTop + logger.debug(`VirtualList: scrolling to index ${index}`, { + clampedIndex, scrollTop, columnCount: this.columnCount, total: this.totalRowCount, visibleRows: this.visibleRows, beforeHeight: this.beforeHeight, + }) + }) }, onScroll() { this._onScrollHandle ??= requestAnimationFrame(() => { this._onScrollHandle = null - const topScroll = this.$el.scrollTop - this.beforeHeight - const index = Math.floor(topScroll / this.itemHeight) * this.columnCount + + const index = this.scrollPosToIndex(this.$el.scrollTop) + if (index === this.index) { + return + } + // Max 0 to prevent negative index - this.index = Math.max(0, index) + this.index = Math.max(0, Math.floor(index)) this.$emit('scroll') }) }, + + // Convert scroll position to index + // It should be the opposite of `indexToScrollPos` + scrollPosToIndex(scrollPos: number): number { + const topScroll = scrollPos - this.beforeHeight + // Max 0 to prevent negative index + return Math.max(0, Math.floor(topScroll / this.itemHeight)) * this.columnCount + }, + + // Convert index to scroll position + // It should be the opposite of `scrollPosToIndex` + indexToScrollPos(index: number): number { + return Math.floor(index / this.columnCount) * this.itemHeight + this.beforeHeight + }, + + /** + * Update the height variables. + * To be called by resize observer and `onMount` + */ + updateHeightVariables(): void { + this.tableHeight = this.$el?.clientHeight ?? 0 + this.beforeHeight = (this.$refs.before as HTMLElement)?.clientHeight ?? 0 + this.footerHeight = (this.$refs.footer as HTMLElement)?.clientHeight ?? 0 + + // Get the header height which consists of table header and filters + const theadHeight = (this.$refs.thead as HTMLElement)?.clientHeight ?? 0 + const filterHeight = (this.$refs.filters as HTMLElement)?.clientHeight ?? 0 + this.headerHeight = theadHeight + filterHeight + }, }, }) </script> |