diff options
Diffstat (limited to 'apps/files/src/components/FileEntry')
6 files changed, 500 insertions, 431 deletions
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, |