diff options
Diffstat (limited to 'apps/files/src/components/FileEntry')
6 files changed, 1281 insertions, 0 deletions
diff --git a/apps/files/src/components/FileEntry/CollectivesIcon.vue b/apps/files/src/components/FileEntry/CollectivesIcon.vue new file mode 100644 index 00000000000..e22b30f4378 --- /dev/null +++ b/apps/files/src/components/FileEntry/CollectivesIcon.vue @@ -0,0 +1,45 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <span :aria-hidden="!title" + :aria-label="title" + class="material-design-icon collectives-icon" + role="img" + v-bind="$attrs" + @click="$emit('click', $event)"> + <svg :fill="fillColor" + class="material-design-icon__svg" + :width="size" + :height="size" + viewBox="0 0 16 16"> + <path d="M2.9,8.8c0-1.2,0.4-2.4,1.2-3.3L0.3,6c-0.2,0-0.3,0.3-0.1,0.4l2.7,2.6C2.9,9,2.9,8.9,2.9,8.8z" /> + <path d="M8,3.7c0.7,0,1.3,0.1,1.9,0.4L8.2,0.6c-0.1-0.2-0.3-0.2-0.4,0L6.1,4C6.7,3.8,7.3,3.7,8,3.7z" /> + <path d="M3.7,11.5L3,15.2c0,0.2,0.2,0.4,0.4,0.3l3.3-1.7C5.4,13.4,4.4,12.6,3.7,11.5z" /> + <path d="M15.7,6l-3.7-0.5c0.7,0.9,1.2,2,1.2,3.3c0,0.1,0,0.2,0,0.3l2.7-2.6C15.9,6.3,15.9,6.1,15.7,6z" /> + <path d="M12.3,11.5c-0.7,1.1-1.8,1.9-3,2.2l3.3,1.7c0.2,0.1,0.4-0.1,0.4-0.3L12.3,11.5z" /> + <path d="M9.6,10.1c-0.4,0.5-1,0.8-1.6,0.8c-1.1,0-2-0.9-2.1-2C5.9,7.7,6.8,6.7,8,6.7c0.6,0,1.1,0.3,1.5,0.7 c0.1,0.1,0.1,0.1,0.2,0.1h1.4c0.2,0,0.4-0.2,0.3-0.5c-0.7-1.3-2.1-2.2-3.8-2.1C5.8,5,4.3,6.6,4.1,8.5C4,10.8,5.8,12.7,8,12.7 c1.6,0,2.9-0.9,3.5-2.3c0.1-0.2-0.1-0.4-0.3-0.4H9.9C9.8,10,9.7,10,9.6,10.1z" /> + </svg> + </span> +</template> + +<script> +export default { + name: 'CollectivesIcon', + props: { + title: { + type: String, + default: '', + }, + fillColor: { + type: String, + default: 'currentColor', + }, + size: { + type: Number, + default: 24, + }, + }, +} +</script> diff --git a/apps/files/src/components/FileEntry/FavoriteIcon.vue b/apps/files/src/components/FileEntry/FavoriteIcon.vue new file mode 100644 index 00000000000..c66cb8fbd7f --- /dev/null +++ b/apps/files/src/components/FileEntry/FavoriteIcon.vue @@ -0,0 +1,76 @@ +<!-- + - 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> + +<script lang="ts"> +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/components/NcIconSvgWrapper' + +/** + * A favorite icon to be used for overlaying favorite entries like the file preview / icon + * It has a stroke around the star icon to ensure enough contrast for accessibility. + * + * If the background has a hover state you might want to also apply it to the stroke like this: + * ```scss + * .parent:hover :deep(.favorite-marker-icon svg path) { + * stroke: var(--color-background-hover); + * } + * ``` + */ +export default defineComponent({ + name: 'FavoriteIcon', + components: { + NcIconSvgWrapper, + }, + data() { + return { + StarSvg, + } + }, + 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 + const el = this.$el.querySelector('svg') + el?.setAttribute?.('viewBox', '-4 -4 30 30') + }, + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> +.favorite-marker-icon { + color: var(--color-favorite); + // Override NcIconSvgWrapper defaults (clickable area) + min-width: unset !important; + min-height: unset !important; + + :deep() { + svg { + // We added a stroke for a11y so we must increase the size to include the stroke + width: 20px !important; + height: 20px !important; + + // Override NcIconSvgWrapper defaults of 20px + max-width: unset !important; + max-height: unset !important; + + // Sow a border around the icon for better contrast + path { + stroke: var(--color-main-background); + stroke-width: 8px; + stroke-linejoin: round; + paint-order: stroke; + } + } + } +} +</style> diff --git a/apps/files/src/components/FileEntry/FileEntryActions.vue b/apps/files/src/components/FileEntry/FileEntryActions.vue new file mode 100644 index 00000000000..5c537d878fe --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryActions.vue @@ -0,0 +1,399 @@ +<!-- + - 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> + <!-- Render actions --> + <CustomElementRender v-for="action in enabledRenderActions" + :key="action.id" + :class="'files-list__row-action-' + action.id" + :current-view="currentView" + :render="action.renderInline" + :source="source" + class="files-list__row-action--inline" /> + + <!-- Menu actions --> + <NcActions ref="actionsMenu" + :boundaries-element="getBoundariesElement" + :container="getBoundariesElement" + :force-name="true" + type="tertiary" + :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" + :inline="enabledInlineActions.length" + :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--inline': index < enabledInlineActions.length, + 'files-list__row-action--menu': isValidMenu(action), + }" + :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> + + <!-- 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" data-cy-files-list-row-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-action-${action.id}`" + class="files-list__row-action--submenu" + 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="isLoadingAction(action)" /> + <NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" /> + </template> + {{ actionDisplayName(action) }} + </NcActionButton> + </template> + </NcActions> + </td> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { FileAction, Node } from '@nextcloud/files' + +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 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, + CustomElementRender, + NcActionButton, + NcActions, + NcActionSeparator, + NcIconSvgWrapper, + NcLoadingIcon, + }, + + mixins: [actionsMixins], + + props: { + opened: { + type: Boolean, + default: false, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + 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 { + activeStore, + currentDir, + currentView, + enabledFileActions, + filesListWidth, + t, + } + }, + + computed: { + isActive() { + return this.activeStore?.activeNode?.source === this.source.source + }, + + isLoading() { + return this.source.status === NodeStatus.LOADING + }, + + // Enabled action that are displayed inline + enabledInlineActions() { + if (this.filesListWidth < 768 || this.gridMode) { + return [] + } + 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 + enabledRenderActions() { + if (this.gridMode) { + return [] + } + return this.enabledFileActions.filter(action => typeof action.renderInline === 'function') + }, + + // Actions shown in the menu + enabledMenuActions() { + // If we're in a submenu, only render the inline + // actions before the filtered submenu + if (this.openedSubmenu) { + return this.enabledInlineActions + } + + const actions = [ + // Showing inline first for the NcActions inline prop + ...this.enabledInlineActions, + // Then the rest + ...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) + }) + + // Generate list of all top-level actions ids + const topActionsIds = actions.filter(action => !action.parent).map(action => action.id) as string[] + + // Filter actions that are not top-level AND have a valid parent + return actions.filter(action => !(action.parent && topActionsIds.includes(action.parent))) + }, + + renderedNonDestructiveActions() { + return this.enabledMenuActions.filter(action => !action.destructive) + }, + + renderedDestructiveActions() { + return this.enabledMenuActions.filter(action => action.destructive) + }, + + openedMenu: { + get() { + return this.opened + }, + set(value) { + this.$emit('update:opened', value) + }, + }, + + /** + * Making this a function in case the files-list + * reference changes in the future. That way we're + * sure there is one at the time we call it. + */ + getBoundariesElement() { + return document.querySelector('.app-content > .files-list') + }, + }, + + 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) { + 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 + } + }, + + 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 + } + + // Make sure we set the node as active + this.activeStore.activeNode = this.source + + // Execute the action + await executeAction(action) + }, + + onKeyDown(event: KeyboardEvent) { + // Don't react to the event if the file row is not active + if (!this.isActive) { + return + } + + // ESC close the action menu if opened + if (event.key === 'Escape' && this.openedMenu) { + this.openedMenu = false + } + + // a open the action menu + if (event.key === 'a' && !this.openedMenu) { + this.openedMenu = true + } + }, + + onMenuClose() { + // We reset the submenu state when the menu is closing + this.openedSubmenu = null + }, + + onMenuClosed() { + // We reset the actions menu state when the menu is finally closed + this.openedMenu = false + }, + }, +}) +</script> + +<style lang="scss"> +// Allow right click to define the position of the menu +// only if defined +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"] { + // 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 { + display: none; + } +} +</style> + +<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); + } + + // 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 new file mode 100644 index 00000000000..5b80a971118 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryCheckbox.vue @@ -0,0 +1,173 @@ +<!-- + - 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" :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 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 { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { defineComponent } from 'vue' + +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.ts' + +export default defineComponent({ + name: 'FileEntryCheckbox', + + components: { + NcCheckboxRadioSwitch, + NcLoadingIcon, + }, + + props: { + fileid: { + type: Number, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + }, + + 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.source.source) + }, + index() { + return this.nodes.findIndex((node: Node) => node.source === this.source.source) + }, + isFile() { + return this.source.type === FileType.File + }, + ariaLabel() { + return this.isFile + ? 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: { + onSelectionChange(selected: boolean) { + const newSelectedIndex = this.index + const lastSelectedIndex = this.selectionStore.lastSelectedIndex + + // Get the last selected and select all files in between + if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) { + 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.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(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 + this.selectionStore.set(selection) + return + } + + const selection = selected + ? [...this.selectedFiles, this.source.source] + : this.selectedFiles.filter(source => source !== this.source.source) + + logger.debug('Updating selection', { selection }) + this.selectionStore.set(selection) + this.selectionStore.setLastIndex(newSelectedIndex) + }, + + resetSelection() { + this.selectionStore.reset() + }, + + 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 new file mode 100644 index 00000000000..418f9581eb6 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryName.vue @@ -0,0 +1,288 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <!-- Rename input --> + <form v-if="isRenaming" + ref="renameForm" + v-on-click-outside="onRename" + :aria-label="t('files', 'Rename file')" + class="files-list__row-rename" + @submit.prevent.stop="onRename"> + <NcTextField ref="renameInput" + :label="renameLabel" + :autofocus="true" + :minlength="1" + :required="true" + :value.sync="newName" + enterkeyhint="done" + @keyup.esc="stopRenaming" /> + </form> + + <component :is="linkTo.is" + v-else + ref="basename" + class="files-list__row-name-link" + data-cy-files-list-row-name-link + 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 { showError, showSuccess } from '@nextcloud/dialogs' +import { FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { defineComponent, inject } from 'vue' + +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 { useRouteParameters } from '../../composables/useRouteParameters.ts' +import { useUserConfigStore } from '../../store/userconfig.ts' +import logger from '../../logger.ts' + +export default defineComponent({ + name: 'FileEntryName', + + components: { + NcTextField, + }, + + props: { + /** + * The filename without extension + */ + basename: { + type: String, + required: true, + }, + /** + * The extension of the filename + */ + extension: { + type: String, + required: true, + }, + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + source: { + type: Object as PropType<Node>, + required: true, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + 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, + } + }, + + computed: { + isRenaming() { + return this.renamingStore.renamingNode === this.source + }, + isRenamingSmallScreen() { + return this.isRenaming && this.filesListWidth < 512 + }, + newName: { + get(): string { + return this.renamingStore.newNodeName + }, + set(newName: string) { + this.renamingStore.newNodeName = newName + }, + }, + + renameLabel() { + const matchLabel: Record<FileType, string> = { + [FileType.File]: t('files', 'Filename'), + [FileType.Folder]: t('files', 'Folder name'), + } + return matchLabel[this.source.type] + }, + + linkTo() { + if (this.source.status === NodeStatus.FAILED) { + return { + is: 'span', + params: { + title: t('files', 'This node is unavailable'), + }, + } + } + + if (this.defaultFileAction) { + const displayName = this.defaultFileAction.displayName([this.source], this.currentView) + return { + is: 'button', + params: { + 'aria-label': displayName, + title: 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', + } + }, + }, + + watch: { + /** + * If renaming starts, select the filename + * in the input, without the extension. + * @param renaming + */ + isRenaming: { + immediate: true, + handler(renaming: boolean) { + if (renaming) { + this.startRenaming() + } + }, + }, + + newName() { + // Check validity of the new name + const newName = this.newName.trim?.() || '' + const input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + return + } + + 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() + } + }) + }, + }, + + 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 input = (this.$refs.renameInput as Vue|undefined)?.$el.querySelector('input') + if (!input) { + logger.error('Could not find the rename input') + return + } + 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 + } + + // Reset the renaming store + this.renamingStore.$reset() + }, + + // Rename and move the file + async onRename() { + const newName = this.newName.trim?.() || '' + const form = this.$refs.renameForm as HTMLFormElement + if (!form.checkValidity()) { + showError(t('files', 'Invalid filename.') + ' ' + getFilenameValidity(newName)) + return + } + + const oldName = this.source.basename + if (newName === oldName) { + this.stopRenaming() + return + } + + try { + 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 + } + } catch (error) { + logger.error(error as Error) + showError((error as Error).message) + // And ensure we reset to the renaming state + this.startRenaming() + } + }, + + t, + }, +}) +</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 new file mode 100644 index 00000000000..3d0fffe7584 --- /dev/null +++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue @@ -0,0 +1,300 @@ +<!-- + - 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'"> + <FolderOpenIcon v-if="dragover" v-once /> + <template v-else> + <FolderIcon v-once /> + <OverlayIcon :is="folderOverlay" + v-if="folderOverlay" + class="files-list__row-icon-overlay" /> + </template> + </template> + + <!-- 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 /> + + <!-- Favorite icon --> + <span v-if="isFavorite" class="files-list__row-icon-favorite"> + <FavoriteIcon v-once /> + </span> + + <OverlayIcon :is="fileOverlay" + v-if="fileOverlay" + class="files-list__row-icon-overlay files-list__row-icon-overlay--file" /> + </span> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { UserConfig } from '../../types.ts' + +import { Node, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +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' +import FileIcon from 'vue-material-design-icons/File.vue' +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/NetworkOutline.vue' +import TagIcon from 'vue-material-design-icons/Tag.vue' +import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue' + +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 defineComponent({ + name: 'FileEntryPreview', + + components: { + AccountGroupIcon, + AccountPlusIcon, + CollectivesIcon, + FavoriteIcon, + FileIcon, + FolderIcon, + FolderOpenIcon, + KeyIcon, + LinkIcon, + NetworkIcon, + TagIcon, + }, + + props: { + source: { + type: Object as PropType<Node>, + required: true, + }, + dragover: { + type: Boolean, + default: false, + }, + gridMode: { + type: Boolean, + default: false, + }, + }, + + setup() { + const userConfigStore = useUserConfigStore() + const isPublic = isPublicShare() + const publicSharingToken = getSharingToken() + + return { + userConfigStore, + + isPublic, + publicSharingToken, + } + }, + + data() { + return { + backgroundFailed: undefined as boolean | undefined, + backgroundLoaded: false, + } + }, + + computed: { + isFavorite(): boolean { + return this.source.attributes.favorite === 1 + }, + + userConfig(): UserConfig { + return this.userConfigStore.userConfig + }, + cropPreviews(): boolean { + return this.userConfig.crop_image_previews === true + }, + + previewUrl() { + if (this.source.type === FileType.Folder) { + return null + } + + if (this.backgroundFailed === true) { + 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 + || (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 + url.searchParams.set('x', this.gridMode ? '128' : '32') + 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 + } catch (e) { + return null + } + }, + + fileOverlay() { + if (isLivePhoto(this.source)) { + return PlayCircleIcon + } + + return null + }, + + folderOverlay() { + if (this.source.type !== FileType.Folder) { + return null + } + + // Encrypted folders + if (this.source?.attributes?.['is-encrypted'] === 1) { + return KeyIcon + } + + // System tags + if (this.source?.attributes?.['is-tag']) { + return TagIcon + } + + // Link and mail shared folders + const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[] + if (shareTypes.some(type => type === ShareType.Link || type === ShareType.Email)) { + return LinkIcon + } + + // Shared folders + if (shareTypes.length > 0) { + return AccountPlusIcon + } + + switch (this.source?.attributes?.['mount-type']) { + case 'external': + case 'external-session': + return NetworkIcon + case 'group': + 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() { + // 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, + }, +}) +</script> |