mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
Merge pull request #40917 from nextcloud/feat/gridview
This commit is contained in:
commit
106bf6cf87
17 changed files with 1707 additions and 698 deletions
|
|
@ -47,6 +47,12 @@ class UserConfig {
|
|||
'default' => true,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
[
|
||||
// Whether to show the files list in grid view or not
|
||||
'key' => 'grid_view',
|
||||
'default' => false,
|
||||
'allowed' => [true, false],
|
||||
],
|
||||
];
|
||||
|
||||
protected IConfig $config;
|
||||
|
|
|
|||
|
|
@ -37,124 +37,41 @@
|
|||
<span v-if="source.attributes.failed" class="files-list__row--failed" />
|
||||
|
||||
<!-- Checkbox -->
|
||||
<td class="files-list__row-checkbox">
|
||||
<NcLoadingIcon v-if="isLoading" />
|
||||
<NcCheckboxRadioSwitch v-else-if="visible"
|
||||
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
|
||||
:checked="isSelected"
|
||||
@update:checked="onSelectionChange" />
|
||||
</td>
|
||||
<FileEntryCheckbox v-if="visible"
|
||||
:display-name="displayName"
|
||||
:fileid="fileid"
|
||||
:is-loading="isLoading"
|
||||
:nodes="nodes" />
|
||||
|
||||
<!-- Link to file -->
|
||||
<td class="files-list__row-name" data-cy-files-list-row-name>
|
||||
<!-- Icon or preview -->
|
||||
<span class="files-list__row-icon" @click="execDefaultAction">
|
||||
<template v-if="source.type === 'folder'">
|
||||
<FolderOpenIcon v-if="dragover" />
|
||||
<template v-else>
|
||||
<FolderIcon />
|
||||
<OverlayIcon :is="folderOverlay"
|
||||
v-if="folderOverlay"
|
||||
class="files-list__row-icon-overlay" />
|
||||
</template>
|
||||
</template>
|
||||
<FileEntryPreview ref="preview"
|
||||
:source="source"
|
||||
:dragover="dragover"
|
||||
@click.native="execDefaultAction" />
|
||||
|
||||
<!-- 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}"
|
||||
:src="previewUrl"
|
||||
@error="backgroundFailed = true"
|
||||
@load="backgroundFailed = false">
|
||||
|
||||
<FileIcon v-else />
|
||||
|
||||
<!-- Favorite icon -->
|
||||
<span v-if="isFavorite"
|
||||
class="files-list__row-icon-favorite"
|
||||
:aria-label="t('files', 'Favorite')">
|
||||
<FavoriteIcon />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Rename input -->
|
||||
<form v-if="isRenaming"
|
||||
v-on-click-outside="stopRenaming"
|
||||
:aria-hidden="!isRenaming"
|
||||
: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="checkInputValidity"
|
||||
@keyup.esc="stopRenaming" />
|
||||
</form>
|
||||
|
||||
<a v-else
|
||||
ref="basename"
|
||||
:aria-hidden="isRenaming"
|
||||
class="files-list__row-name-link"
|
||||
data-cy-files-list-row-name-link
|
||||
v-bind="linkTo"
|
||||
@click="execDefaultAction">
|
||||
<!-- 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" />
|
||||
</span>
|
||||
</a>
|
||||
<FileEntryName ref="name"
|
||||
:display-name="displayName"
|
||||
:extension="extension"
|
||||
:files-list-width="filesListWidth"
|
||||
:nodes="nodes"
|
||||
:source="source"
|
||||
@click="execDefaultAction" />
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td v-show="!isRenamingSmallScreen"
|
||||
<FileEntryActions v-show="!isRenamingSmallScreen"
|
||||
ref="actions"
|
||||
:class="`files-list__row-actions-${uniqueId}`"
|
||||
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 v-if="visible"
|
||||
ref="actionsMenu"
|
||||
:boundaries-element="getBoundariesElement()"
|
||||
:container="getBoundariesElement()"
|
||||
:disabled="isLoading"
|
||||
:force-name="true"
|
||||
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
|
||||
:inline="enabledInlineActions.length"
|
||||
:open.sync="openedMenu">
|
||||
<NcActionButton v-for="action in enabledMenuActions"
|
||||
:key="action.id"
|
||||
:class="'files-list__row-action-' + action.id"
|
||||
:close-after-click="true"
|
||||
:data-cy-files-list-row-action="action.id"
|
||||
: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)" />
|
||||
</template>
|
||||
{{ actionDisplayName(action) }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</td>
|
||||
:files-list-width="filesListWidth"
|
||||
:loading.sync="loading"
|
||||
:opened.sync="openedMenu"
|
||||
:source="source"
|
||||
:visible="visible" />
|
||||
|
||||
<!-- Size -->
|
||||
<td v-if="isSizeAvailable"
|
||||
<td v-if="!compact && isSizeAvailable"
|
||||
:style="sizeOpacity"
|
||||
class="files-list__row-size"
|
||||
data-cy-files-list-row-size
|
||||
|
|
@ -163,7 +80,7 @@
|
|||
</td>
|
||||
|
||||
<!-- Mtime -->
|
||||
<td v-if="isMtimeAvailable"
|
||||
<td v-if="!compact && isMtimeAvailable"
|
||||
:style="mtimeOpacity"
|
||||
class="files-list__row-mtime"
|
||||
data-cy-files-list-row-mtime
|
||||
|
|
@ -189,80 +106,43 @@
|
|||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { emit, subscribe } from '@nextcloud/event-bus'
|
||||
import { extname, join } from 'path'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File as NcFile, FileAction, NodeStatus, Node } from '@nextcloud/files'
|
||||
import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
|
||||
import { getUploader } from '@nextcloud/upload'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { Type as ShareType } from '@nextcloud/sharing'
|
||||
import { vOnClickOutside } from '@vueuse/components'
|
||||
import axios from '@nextcloud/axios'
|
||||
import moment from '@nextcloud/moment'
|
||||
import Vue from 'vue'
|
||||
|
||||
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.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 TagIcon from 'vue-material-design-icons/Tag.vue'
|
||||
import LinkIcon from 'vue-material-design-icons/Link.vue'
|
||||
import NetworkIcon from 'vue-material-design-icons/Network.vue'
|
||||
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
|
||||
import { action as sidebarAction } from '../actions/sidebarAction.ts'
|
||||
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
|
||||
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import { hashCode } from '../utils/hashUtils.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import { useActionsMenuStore } from '../store/actionsmenu.ts'
|
||||
import { useDragAndDropStore } from '../store/dragging.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useKeyboardStore } from '../store/keyboard.ts'
|
||||
import { useRenamingStore } from '../store/renaming.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useUserConfigStore } from '../store/userconfig.ts'
|
||||
import CustomElementRender from './CustomElementRender.vue'
|
||||
import FavoriteIcon from './FavoriteIcon.vue'
|
||||
import FileEntryActions from './FileEntry/FileEntryActions.vue'
|
||||
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
|
||||
import FileEntryName from './FileEntry/FileEntryName.vue'
|
||||
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
|
||||
import logger from '../logger.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
|
||||
// The registered actions list
|
||||
const actions = getFileActions()
|
||||
|
||||
Vue.directive('onClickOutside', vOnClickOutside)
|
||||
|
||||
const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntry',
|
||||
|
||||
components: {
|
||||
AccountGroupIcon,
|
||||
AccountPlusIcon,
|
||||
CustomElementRender,
|
||||
FavoriteIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
KeyIcon,
|
||||
LinkIcon,
|
||||
NcActionButton,
|
||||
NcActions,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcIconSvgWrapper,
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
NetworkIcon,
|
||||
TagIcon,
|
||||
FileEntryActions,
|
||||
FileEntryCheckbox,
|
||||
FileEntryName,
|
||||
FileEntryPreview,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -282,10 +162,6 @@ export default Vue.extend({
|
|||
type: [Folder, NcFile, Node] as PropType<Node>,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
nodes: {
|
||||
type: Array as PropType<Node[]>,
|
||||
required: true,
|
||||
|
|
@ -294,48 +170,41 @@ export default Vue.extend({
|
|||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const actionsMenuStore = useActionsMenuStore()
|
||||
const draggingStore = useDragAndDropStore()
|
||||
const filesStore = useFilesStore()
|
||||
const keyboardStore = useKeyboardStore()
|
||||
const renamingStore = useRenamingStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const userConfigStore = useUserConfigStore()
|
||||
return {
|
||||
actionsMenuStore,
|
||||
draggingStore,
|
||||
filesStore,
|
||||
keyboardStore,
|
||||
renamingStore,
|
||||
selectionStore,
|
||||
userConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
backgroundFailed: undefined,
|
||||
loading: '',
|
||||
dragover: false,
|
||||
|
||||
NodeStatus,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
userConfig() {
|
||||
return this.userConfigStore.userConfig
|
||||
},
|
||||
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
currentView(): View {
|
||||
return this.$navigation.active as View
|
||||
},
|
||||
columns() {
|
||||
// Hide columns if the list is too small
|
||||
if (this.filesListWidth < 512) {
|
||||
if (this.filesListWidth < 512 || this.compact) {
|
||||
return []
|
||||
}
|
||||
return this.currentView?.columns || []
|
||||
|
|
@ -351,6 +220,12 @@ export default Vue.extend({
|
|||
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) {
|
||||
|
|
@ -418,73 +293,6 @@ export default Vue.extend({
|
|||
return ''
|
||||
},
|
||||
|
||||
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.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_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
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
linkTo() {
|
||||
if (this.source.attributes.failed) {
|
||||
return {
|
||||
title: t('files', 'This node is unavailable'),
|
||||
is: 'span',
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enabledDefaultActions.length > 0) {
|
||||
const action = this.enabledDefaultActions[0]
|
||||
const displayName = action.displayName([this.source], this.currentView)
|
||||
return {
|
||||
title: displayName,
|
||||
role: 'button',
|
||||
}
|
||||
}
|
||||
|
||||
if (this.source?.permissions & Permission.READ) {
|
||||
return {
|
||||
download: this.source.basename,
|
||||
href: this.source.source,
|
||||
title: t('files', 'Download file {name}', { name: this.displayName }),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is: 'span',
|
||||
}
|
||||
},
|
||||
|
||||
draggingFiles() {
|
||||
return this.draggingStore.dragging
|
||||
},
|
||||
|
|
@ -495,124 +303,12 @@ export default Vue.extend({
|
|||
return this.selectedFiles.includes(this.fileid)
|
||||
},
|
||||
|
||||
cropPreviews() {
|
||||
return this.userConfig.crop_image_previews
|
||||
},
|
||||
previewUrl() {
|
||||
if (this.source.type === FileType.Folder) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.backgroundFailed === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const previewUrl = this.source.attributes.previewUrl
|
||||
|| generateUrl('/core/preview?fileId={fileid}', {
|
||||
fileid: this.fileid,
|
||||
})
|
||||
const url = new URL(window.location.origin + previewUrl)
|
||||
|
||||
// Request tiny previews
|
||||
url.searchParams.set('x', '32')
|
||||
url.searchParams.set('y', '32')
|
||||
url.searchParams.set('mimeFallback', 'true')
|
||||
|
||||
// Handle cropping
|
||||
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
|
||||
return url.href
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// 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) {
|
||||
return []
|
||||
}
|
||||
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
|
||||
},
|
||||
|
||||
// Enabled action that are displayed inline with a custom render function
|
||||
enabledRenderActions() {
|
||||
if (!this.visible) {
|
||||
return []
|
||||
}
|
||||
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
|
||||
},
|
||||
|
||||
// Default actions
|
||||
enabledDefaultActions() {
|
||||
return this.enabledActions.filter(action => !!action?.default)
|
||||
},
|
||||
|
||||
// Actions shown in the menu
|
||||
enabledMenuActions() {
|
||||
return [
|
||||
// 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'),
|
||||
].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)
|
||||
})
|
||||
},
|
||||
openedMenu: {
|
||||
get() {
|
||||
return this.actionsMenuStore.opened === this.uniqueId
|
||||
},
|
||||
set(opened) {
|
||||
this.actionsMenuStore.opened = opened ? this.uniqueId : null
|
||||
},
|
||||
},
|
||||
|
||||
uniqueId() {
|
||||
return hashCode(this.source.source)
|
||||
},
|
||||
|
||||
isFavorite() {
|
||||
return this.source.attributes.favorite === 1
|
||||
},
|
||||
isLoading() {
|
||||
return this.source.status === NodeStatus.LOADING
|
||||
},
|
||||
|
||||
renameLabel() {
|
||||
const matchLabel: Record<FileType, string> = {
|
||||
[FileType.File]: t('files', 'File name'),
|
||||
[FileType.Folder]: t('files', 'Folder name'),
|
||||
}
|
||||
return matchLabel[this.source.type]
|
||||
},
|
||||
|
||||
isRenaming() {
|
||||
return this.renamingStore.renamingNode === this.source
|
||||
},
|
||||
isRenamingSmallScreen() {
|
||||
return this.isRenaming && this.filesListWidth < 512
|
||||
},
|
||||
newName: {
|
||||
get() {
|
||||
return this.renamingStore.newName
|
||||
},
|
||||
set(newName) {
|
||||
this.renamingStore.newName = newName
|
||||
},
|
||||
},
|
||||
|
||||
isActive() {
|
||||
return this.fileid === this.currentFileId?.toString?.()
|
||||
|
|
@ -643,6 +339,15 @@ export default Vue.extend({
|
|||
|
||||
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: {
|
||||
|
|
@ -653,17 +358,6 @@ export default Vue.extend({
|
|||
source() {
|
||||
this.resetState()
|
||||
},
|
||||
|
||||
/**
|
||||
* If renaming starts, select the file name
|
||||
* in the input, without the extension.
|
||||
* @param renaming
|
||||
*/
|
||||
isRenaming(renaming) {
|
||||
if (renaming) {
|
||||
this.startRenaming()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
|
|
@ -675,96 +369,12 @@ export default Vue.extend({
|
|||
// Reset loading state
|
||||
this.loading = ''
|
||||
|
||||
// Reset background state
|
||||
this.backgroundFailed = undefined
|
||||
if (this.$refs.previewImg) {
|
||||
this.$refs.previewImg.src = ''
|
||||
}
|
||||
this.$refs.preview.reset()
|
||||
|
||||
// Close menu
|
||||
this.openedMenu = false
|
||||
},
|
||||
|
||||
async onActionClick(action) {
|
||||
const displayName = action.displayName([this.source], this.currentView)
|
||||
try {
|
||||
// Set the loading marker
|
||||
this.loading = action.id
|
||||
Vue.set(this.source, 'status', NodeStatus.LOADING)
|
||||
|
||||
const success = await action.exec(this.source, this.currentView, this.currentDir)
|
||||
|
||||
// If the action returns null, we stay silent
|
||||
if (success === null) {
|
||||
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.loading = ''
|
||||
Vue.set(this.source, 'status', undefined)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
openDetailsIfAvailable(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
|
||||
sidebarAction.exec(this.source, this.currentView, this.currentDir)
|
||||
}
|
||||
},
|
||||
|
||||
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.fileid)
|
||||
|
||||
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?.())
|
||||
.slice(start, end + 1)
|
||||
|
||||
// If already selected, update the new selection _without_ the current file
|
||||
const selection = [...lastSelection, ...filesToSelect]
|
||||
.filter(fileid => !isAlreadySelected || fileid !== this.fileid)
|
||||
|
||||
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.fileid]
|
||||
: this.selectedFiles.filter(fileid => fileid !== this.fileid)
|
||||
|
||||
logger.debug('Updating selection', { selection })
|
||||
this.selectionStore.set(selection)
|
||||
this.selectionStore.setLastIndex(newSelectedIndex)
|
||||
},
|
||||
|
||||
// Open the actions menu on right click
|
||||
onRightClick(event) {
|
||||
// If already opened, fallback to default browser
|
||||
|
|
@ -781,166 +391,16 @@ export default Vue.extend({
|
|||
event.stopPropagation()
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
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 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 }))
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
checkIfNodeExists(name) {
|
||||
return this.nodes.find(node => node.basename === name && node !== this.source)
|
||||
execDefaultAction(...args) {
|
||||
this.$refs.actions.execDefaultAction(...args)
|
||||
},
|
||||
|
||||
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
|
||||
if (!input) {
|
||||
logger.error('Could not find the rename input')
|
||||
return
|
||||
}
|
||||
input.setSelectionRange(0, length)
|
||||
input.focus()
|
||||
|
||||
// Trigger a keyup event to update the input validity
|
||||
input.dispatchEvent(new Event('keyup'))
|
||||
})
|
||||
},
|
||||
stopRenaming() {
|
||||
if (!this.isRenaming) {
|
||||
return
|
||||
openDetailsIfAvailable(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
|
||||
sidebarAction.exec(this.source, this.currentView, this.currentDir)
|
||||
}
|
||||
|
||||
// Reset the renaming store
|
||||
this.renamingStore.$reset()
|
||||
},
|
||||
|
||||
// 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'))
|
||||
return
|
||||
}
|
||||
|
||||
if (oldName === newName) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
|
||||
} finally {
|
||||
this.loading = false
|
||||
Vue.set(this.source, 'status', undefined)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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')
|
||||
},
|
||||
|
||||
actionDisplayName(action: FileAction) {
|
||||
if (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)
|
||||
},
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
|
|
@ -1057,43 +517,3 @@ export default Vue.extend({
|
|||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang='scss'>
|
||||
/* Hover effect on tbody lines only */
|
||||
tr {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Folder overlay
|
||||
.files-list__row-icon-overlay {
|
||||
position: absolute;
|
||||
max-height: 18px;
|
||||
max-width: 18px;
|
||||
color: var(--color-main-background);
|
||||
// better alignment with the folder icon
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Preview not loaded animation effect */
|
||||
.files-list__row-icon-preview:not(.files-list__row-icon-preview--loaded) {
|
||||
background: var(--color-loading-dark);
|
||||
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* @keyframes preview-gradient-fade {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
} */
|
||||
</style>
|
||||
|
|
|
|||
247
apps/files/src/components/FileEntry/FileEntryActions.vue
Normal file
247
apps/files/src/components/FileEntry/FileEntryActions.vue
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
<template>
|
||||
<td v-show="visible"
|
||||
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 v-if="visible"
|
||||
ref="actionsMenu"
|
||||
:boundaries-element="getBoundariesElement()"
|
||||
:container="getBoundariesElement()"
|
||||
:disabled="isLoading || loading !== ''"
|
||||
:force-name="true"
|
||||
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
|
||||
:inline="enabledInlineActions.length"
|
||||
:open.sync="openedMenu">
|
||||
<NcActionButton v-for="action in enabledMenuActions"
|
||||
:key="action.id"
|
||||
:class="'files-list__row-action-' + action.id"
|
||||
:close-after-click="true"
|
||||
:data-cy-files-list-row-action="action.id"
|
||||
: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)" />
|
||||
</template>
|
||||
{{ actionDisplayName(action) }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</td>
|
||||
</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 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 logger from '../../logger.js'
|
||||
|
||||
// The registered actions list
|
||||
const actions = getFileActions()
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntryActions',
|
||||
|
||||
components: {
|
||||
NcActionButton,
|
||||
NcActions,
|
||||
NcIconSvgWrapper,
|
||||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
filesListWidth: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
opened: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
source: {
|
||||
type: Object as PropType<Node>,
|
||||
required: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
gridMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
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))
|
||||
},
|
||||
|
||||
// Enabled action that are displayed inline with a custom render function
|
||||
enabledRenderActions() {
|
||||
if (!this.visible || this.gridMode) {
|
||||
return []
|
||||
}
|
||||
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
|
||||
},
|
||||
|
||||
// Default actions
|
||||
enabledDefaultActions() {
|
||||
return this.enabledActions.filter(action => !!action?.default)
|
||||
},
|
||||
|
||||
// Actions shown in the menu
|
||||
enabledMenuActions() {
|
||||
return [
|
||||
// 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'),
|
||||
].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)
|
||||
})
|
||||
},
|
||||
|
||||
openedMenu: {
|
||||
get() {
|
||||
return this.opened
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:opened', value)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 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 > table.files-list')
|
||||
},
|
||||
|
||||
actionDisplayName(action: FileAction) {
|
||||
if (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)
|
||||
},
|
||||
|
||||
async onActionClick(action) {
|
||||
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)
|
||||
|
||||
const success = await action.exec(this.source, this.currentView, this.currentDir)
|
||||
|
||||
// If the action returns null, we stay silent
|
||||
if (success === null) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
t,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
131
apps/files/src/components/FileEntry/FileEntryCheckbox.vue
Normal file
131
apps/files/src/components/FileEntry/FileEntryCheckbox.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
<template>
|
||||
<td class="files-list__row-checkbox">
|
||||
<NcLoadingIcon v-if="isLoading" />
|
||||
<NcCheckboxRadioSwitch v-else
|
||||
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
|
||||
:checked="isSelected"
|
||||
@update:checked="onSelectionChange" />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Node } from '@nextcloud/files'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import Vue, { PropType } from 'vue'
|
||||
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
|
||||
import { useKeyboardStore } from '../../store/keyboard.ts'
|
||||
import { useSelectionStore } from '../../store/selection.ts'
|
||||
import logger from '../../logger.js'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntryCheckbox',
|
||||
|
||||
components: {
|
||||
NcCheckboxRadioSwitch,
|
||||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileid: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nodes: {
|
||||
type: Array as PropType<Node[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const selectionStore = useSelectionStore()
|
||||
const keyboardStore = useKeyboardStore()
|
||||
return {
|
||||
keyboardStore,
|
||||
selectionStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedFiles() {
|
||||
return this.selectionStore.selected
|
||||
},
|
||||
isSelected() {
|
||||
return this.selectedFiles.includes(this.fileid)
|
||||
},
|
||||
index() {
|
||||
return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid))
|
||||
},
|
||||
},
|
||||
|
||||
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.fileid)
|
||||
|
||||
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?.())
|
||||
.slice(start, end + 1)
|
||||
|
||||
// If already selected, update the new selection _without_ the current file
|
||||
const selection = [...lastSelection, ...filesToSelect]
|
||||
.filter(fileid => !isAlreadySelected || fileid !== this.fileid)
|
||||
|
||||
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.fileid]
|
||||
: this.selectedFiles.filter(fileid => fileid !== this.fileid)
|
||||
|
||||
logger.debug('Updating selection', { selection })
|
||||
this.selectionStore.set(selection)
|
||||
this.selectionStore.setLastIndex(newSelectedIndex)
|
||||
},
|
||||
|
||||
t,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
330
apps/files/src/components/FileEntry/FileEntryName.vue
Normal file
330
apps/files/src/components/FileEntry/FileEntryName.vue
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
<template>
|
||||
<!-- Rename input -->
|
||||
<form v-if="isRenaming"
|
||||
v-on-click-outside="stopRenaming"
|
||||
: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="checkInputValidity"
|
||||
@keyup.esc="stopRenaming" />
|
||||
</form>
|
||||
|
||||
<a v-else
|
||||
ref="basename"
|
||||
:aria-hidden="isRenaming"
|
||||
class="files-list__row-name-link"
|
||||
data-cy-files-list-row-name-link
|
||||
v-bind="linkTo"
|
||||
@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" />
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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 { translate as t } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
import Vue, { PropType } from 'vue'
|
||||
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
|
||||
import { useRenamingStore } from '../../store/renaming.ts'
|
||||
import logger from '../../logger.js'
|
||||
|
||||
const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntryName',
|
||||
|
||||
components: {
|
||||
NcTextField,
|
||||
},
|
||||
|
||||
props: {
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
extension: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filesListWidth: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
nodes: {
|
||||
type: Array as PropType<Node[]>,
|
||||
required: true,
|
||||
},
|
||||
source: {
|
||||
type: Object as PropType<Node>,
|
||||
required: true,
|
||||
},
|
||||
gridMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const renamingStore = useRenamingStore()
|
||||
return {
|
||||
renamingStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isRenaming() {
|
||||
return this.renamingStore.renamingNode === this.source
|
||||
},
|
||||
isRenamingSmallScreen() {
|
||||
return this.isRenaming && this.filesListWidth < 512
|
||||
},
|
||||
newName: {
|
||||
get() {
|
||||
return this.renamingStore.newName
|
||||
},
|
||||
set(newName) {
|
||||
this.renamingStore.newName = newName
|
||||
},
|
||||
},
|
||||
|
||||
renameLabel() {
|
||||
const matchLabel: Record<FileType, string> = {
|
||||
[FileType.File]: t('files', 'File name'),
|
||||
[FileType.Folder]: t('files', 'Folder name'),
|
||||
}
|
||||
return matchLabel[this.source.type]
|
||||
},
|
||||
|
||||
linkTo() {
|
||||
if (this.source.attributes.failed) {
|
||||
return {
|
||||
title: t('files', 'This node is unavailable'),
|
||||
is: 'span',
|
||||
}
|
||||
}
|
||||
|
||||
const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
|
||||
if (enabledDefaultActions?.length > 0) {
|
||||
const action = enabledDefaultActions[0]
|
||||
const displayName = action.displayName([this.source], this.currentView)
|
||||
return {
|
||||
title: displayName,
|
||||
role: 'button',
|
||||
}
|
||||
}
|
||||
|
||||
if (this.source?.permissions & Permission.READ) {
|
||||
return {
|
||||
download: this.source.basename,
|
||||
href: this.source.source,
|
||||
title: t('files', 'Download file {name}', { name: this.displayName }),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is: 'span',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* If renaming starts, select the file name
|
||||
* in the input, without the extension.
|
||||
* @param renaming
|
||||
*/
|
||||
isRenaming(renaming: boolean) {
|
||||
if (renaming) {
|
||||
this.startRenaming()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
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
|
||||
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 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 }))
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
checkIfNodeExists(name) {
|
||||
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
|
||||
if (!input) {
|
||||
logger.error('Could not find the rename input')
|
||||
return
|
||||
}
|
||||
input.setSelectionRange(0, length)
|
||||
input.focus()
|
||||
|
||||
// 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 oldName = this.source.basename
|
||||
const oldEncodedSource = this.source.encodedSource
|
||||
const newName = this.newName.trim?.() || ''
|
||||
if (newName === '') {
|
||||
showError(t('files', 'Name cannot be empty'))
|
||||
return
|
||||
}
|
||||
|
||||
if (oldName === newName) {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
|
||||
} finally {
|
||||
this.loading = false
|
||||
Vue.set(this.source, 'status', undefined)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
t,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
215
apps/files/src/components/FileEntry/FileEntryPreview.vue
Normal file
215
apps/files/src/components/FileEntry/FileEntryPreview.vue
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
<template>
|
||||
<span class="files-list__row-icon">
|
||||
<template v-if="source.type === 'folder'">
|
||||
<FolderOpenIcon v-if="dragover" />
|
||||
<template v-else>
|
||||
<FolderIcon />
|
||||
<OverlayIcon :is="folderOverlay"
|
||||
v-if="folderOverlay"
|
||||
class="files-list__row-icon-overlay" />
|
||||
</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}"
|
||||
:src="previewUrl"
|
||||
@error="backgroundFailed = true"
|
||||
@load="backgroundFailed = false">
|
||||
|
||||
<FileIcon v-else />
|
||||
|
||||
<!-- Favorite icon -->
|
||||
<span v-if="isFavorite"
|
||||
class="files-list__row-icon-favorite"
|
||||
:aria-label="t('files', 'Favorite')">
|
||||
<FavoriteIcon />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UserConfig } from '../../types.ts'
|
||||
|
||||
import { File, Folder, Node, FileType } from '@nextcloud/files'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { Type as ShareType } from '@nextcloud/sharing'
|
||||
import Vue, { PropType } 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/Network.vue'
|
||||
import TagIcon from 'vue-material-design-icons/Tag.vue'
|
||||
|
||||
import { useUserConfigStore } from '../../store/userconfig.ts'
|
||||
import FavoriteIcon from './FavoriteIcon.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntryPreview',
|
||||
|
||||
components: {
|
||||
AccountGroupIcon,
|
||||
AccountPlusIcon,
|
||||
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()
|
||||
return {
|
||||
userConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
backgroundFailed: undefined as boolean | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
fileid() {
|
||||
return this.source?.fileid?.toString?.()
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
const previewUrl = this.source.attributes.previewUrl
|
||||
|| generateUrl('/core/preview?fileId={fileid}', {
|
||||
fileid: this.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')
|
||||
|
||||
// Handle cropping
|
||||
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
|
||||
return url.href
|
||||
} catch (e) {
|
||||
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.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_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
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
reset() {
|
||||
// Reset background state
|
||||
this.backgroundFailed = undefined
|
||||
if (this.$refs.previewImg) {
|
||||
this.$refs.previewImg.src = ''
|
||||
}
|
||||
},
|
||||
|
||||
t,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
414
apps/files/src/components/FileEntryGrid.vue
Normal file
414
apps/files/src/components/FileEntryGrid.vue
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
|
||||
data-cy-files-list-row
|
||||
:data-cy-files-list-row-fileid="fileid"
|
||||
:data-cy-files-list-row-name="source.basename"
|
||||
:draggable="canDrag"
|
||||
class="files-list__row"
|
||||
@contextmenu="onRightClick"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop">
|
||||
<!-- Failed indicator -->
|
||||
<span v-if="source.attributes.failed" class="files-list__row--failed" />
|
||||
|
||||
<!-- Checkbox -->
|
||||
<FileEntryCheckbox v-if="visible"
|
||||
:display-name="displayName"
|
||||
:fileid="fileid"
|
||||
:is-loading="isLoading"
|
||||
:nodes="nodes" />
|
||||
|
||||
<!-- Link to file -->
|
||||
<td class="files-list__row-name" data-cy-files-list-row-name>
|
||||
<!-- Icon or preview -->
|
||||
<FileEntryPreview ref="preview"
|
||||
:dragover="dragover"
|
||||
:grid-mode="true"
|
||||
:source="source"
|
||||
@click.native="execDefaultAction" />
|
||||
|
||||
<FileEntryName ref="name"
|
||||
:display-name="displayName"
|
||||
:extension="extension"
|
||||
:files-list-width="filesListWidth"
|
||||
:grid-mode="true"
|
||||
:nodes="nodes"
|
||||
:source="source"
|
||||
@click="execDefaultAction" />
|
||||
</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"
|
||||
:visible="visible" />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } 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 { vOnClickOutside } from '@vueuse/components'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { action as sidebarAction } from '../actions/sidebarAction.ts'
|
||||
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
|
||||
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
|
||||
import { hashCode } from '../utils/hashUtils.ts'
|
||||
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
|
||||
import { useActionsMenuStore } from '../store/actionsmenu.ts'
|
||||
import { useDragAndDropStore } from '../store/dragging.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useRenamingStore } from '../store/renaming.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import 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({
|
||||
name: 'FileEntryGrid',
|
||||
|
||||
components: {
|
||||
FileEntryActions,
|
||||
FileEntryCheckbox,
|
||||
FileEntryName,
|
||||
FileEntryPreview,
|
||||
},
|
||||
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
visible: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const actionsMenuStore = useActionsMenuStore()
|
||||
const draggingStore = useDragAndDropStore()
|
||||
const filesStore = useFilesStore()
|
||||
const renamingStore = useRenamingStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
return {
|
||||
actionsMenuStore,
|
||||
draggingStore,
|
||||
filesStore,
|
||||
renamingStore,
|
||||
selectionStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: '',
|
||||
dragover: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
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(...args) {
|
||||
this.$refs.actions.execDefaultAction(...args)
|
||||
},
|
||||
|
||||
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>
|
||||
|
|
@ -159,17 +159,16 @@ export default Vue.extend({
|
|||
<style scoped lang="scss">
|
||||
// Scoped row
|
||||
tr {
|
||||
padding-bottom: 300px;
|
||||
margin-bottom: 300px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
// Prevent hover effect on the whole row
|
||||
background-color: transparent !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
td {
|
||||
user-select: none;
|
||||
// Make sure the cell colors don't apply to column headers
|
||||
color: var(--color-text-maxcontrast) !important;
|
||||
td {
|
||||
user-select: none;
|
||||
// Make sure the cell colors don't apply to column headers
|
||||
color: var(--color-text-maxcontrast) !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@
|
|||
:style="{ height: dndNoticeHeight }" />
|
||||
|
||||
<VirtualList ref="table"
|
||||
:data-component="FileEntry"
|
||||
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
|
||||
:data-key="'source'"
|
||||
:data-sources="nodes"
|
||||
:item-height="56"
|
||||
:grid-mode="userConfig.grid_view"
|
||||
:extra-props="{
|
||||
isMtimeAvailable,
|
||||
isSizeAvailable,
|
||||
|
|
@ -79,8 +79,9 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { Node as NcNode } from '@nextcloud/files'
|
||||
import type { PropType } from 'vue'
|
||||
import type { UserConfig } from '../types.ts'
|
||||
|
||||
import { Fragment } from 'vue-frag'
|
||||
import { getFileListHeaders, Folder, View, Permission } from '@nextcloud/files'
|
||||
|
|
@ -89,8 +90,10 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
|||
import Vue from 'vue'
|
||||
|
||||
import { action as sidebarAction } from '../actions/sidebarAction.ts'
|
||||
import { useUserConfigStore } from '../store/userconfig.ts'
|
||||
import DragAndDropNotice from './DragAndDropNotice.vue'
|
||||
import FileEntry from './FileEntry.vue'
|
||||
import FileEntryGrid from './FileEntryGrid.vue'
|
||||
import FilesListHeader from './FilesListHeader.vue'
|
||||
import FilesListTableFooter from './FilesListTableFooter.vue'
|
||||
import FilesListTableHeader from './FilesListTableHeader.vue'
|
||||
|
|
@ -129,9 +132,17 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const userConfigStore = useUserConfigStore()
|
||||
return {
|
||||
userConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
FileEntry,
|
||||
FileEntryGrid,
|
||||
headers: getFileListHeaders(),
|
||||
scrollToIndex: 0,
|
||||
dragover: false,
|
||||
|
|
@ -140,6 +151,10 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
userConfig(): UserConfig {
|
||||
return this.userConfigStore.userConfig
|
||||
},
|
||||
|
||||
files() {
|
||||
return this.nodes.filter(node => node.type === 'file')
|
||||
},
|
||||
|
|
@ -302,6 +317,14 @@ export default Vue.extend({
|
|||
width: 100%;
|
||||
// Necessary for virtual scrolling absolute
|
||||
position: relative;
|
||||
|
||||
/* Hover effect on tbody lines only */
|
||||
tr {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Before table and thead
|
||||
|
|
@ -340,6 +363,7 @@ export default Vue.extend({
|
|||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
height: var(--row-height);
|
||||
}
|
||||
|
||||
td, th {
|
||||
|
|
@ -465,10 +489,15 @@ export default Vue.extend({
|
|||
width: var(--icon-preview-size);
|
||||
height: var(--icon-preview-size);
|
||||
border-radius: var(--border-radius);
|
||||
background-repeat: no-repeat;
|
||||
// Center and contain the preview
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
|
||||
/* Preview not loaded animation effect */
|
||||
&:not(.files-list__row-icon-preview--loaded) {
|
||||
background: var(--color-loading-dark);
|
||||
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&-favorite {
|
||||
|
|
@ -476,6 +505,16 @@ export default Vue.extend({
|
|||
top: 0px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
// Folder overlay
|
||||
&-overlay {
|
||||
position: absolute;
|
||||
max-height: calc(var(--icon-preview-size) * 0.5);
|
||||
max-width: calc(var(--icon-preview-size) * 0.5);
|
||||
color: var(--color-main-background);
|
||||
// better alignment with the folder icon
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Entry link
|
||||
|
|
@ -518,6 +557,8 @@ export default Vue.extend({
|
|||
|
||||
.files-list__row-name-ext {
|
||||
color: var(--color-text-maxcontrast);
|
||||
// always show the extension
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -541,6 +582,7 @@ export default Vue.extend({
|
|||
}
|
||||
|
||||
.files-list__row-actions {
|
||||
// take as much space as necessary
|
||||
width: auto;
|
||||
|
||||
// Add margin to all cells after the actions
|
||||
|
|
@ -581,3 +623,91 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
</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));
|
||||
--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;
|
||||
justify-content: space-around;
|
||||
justify-items: center;
|
||||
|
||||
tr {
|
||||
width: var(--row-width);
|
||||
height: calc(var(--row-height) + var(--clickable-area));
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
// Checkbox in the top left
|
||||
.files-list__row-checkbox {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
width: var(--clickable-area);
|
||||
height: var(--clickable-area);
|
||||
border-radius: var(--half-clickable-area);
|
||||
}
|
||||
|
||||
// Star icon in the top right
|
||||
.files-list__row-icon-favorite {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--clickable-area);
|
||||
height: var(--clickable-area);
|
||||
}
|
||||
|
||||
.files-list__row-name {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-auto-rows: var(--row-height) var(--clickable-area);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.files-list__row-name-text {
|
||||
margin: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--clickable-area);
|
||||
height: var(--clickable-area);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@
|
|||
</thead>
|
||||
|
||||
<!-- Body -->
|
||||
<tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody>
|
||||
<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="(item, i) in renderedItems"
|
||||
:key="i"
|
||||
:visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
|
||||
v-for="({key, item}, i) in renderedItems"
|
||||
:key="key"
|
||||
:visible="(i >= bufferItems - 1 || index <= bufferItems) && (i <= shownItems - bufferItems)"
|
||||
:source="item"
|
||||
:index="i"
|
||||
v-bind="extraProps" />
|
||||
|
|
@ -23,7 +26,6 @@
|
|||
|
||||
<!-- Footer -->
|
||||
<tfoot v-show="isReady"
|
||||
ref="tfoot"
|
||||
class="files-list__tfoot"
|
||||
data-cy-files-list-tfoot>
|
||||
<slot name="footer" />
|
||||
|
|
@ -32,16 +34,23 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { File, Folder, debounce } from 'debounce'
|
||||
import Vue from 'vue'
|
||||
import type { File, Folder, Node } from '@nextcloud/files'
|
||||
import { debounce } from 'debounce'
|
||||
import Vue, { PropType } from 'vue'
|
||||
|
||||
import filesListWidthMixin from '../mixins/filesListWidth.ts'
|
||||
import logger from '../logger.js'
|
||||
|
||||
// Items to render before and after the visible area
|
||||
const bufferItems = 3
|
||||
interface RecycledPoolItem {
|
||||
key: string,
|
||||
item: Node,
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'VirtualList',
|
||||
|
||||
mixins: [filesListWidthMixin],
|
||||
|
||||
props: {
|
||||
dataComponent: {
|
||||
type: [Object, Function],
|
||||
|
|
@ -52,26 +61,25 @@ export default Vue.extend({
|
|||
required: true,
|
||||
},
|
||||
dataSources: {
|
||||
type: Array as () => (File | Folder)[],
|
||||
required: true,
|
||||
},
|
||||
itemHeight: {
|
||||
type: Number,
|
||||
type: Array as PropType<(File | Folder)[]>,
|
||||
required: true,
|
||||
},
|
||||
extraProps: {
|
||||
type: Object,
|
||||
type: Object as PropType<Record<string, unknown>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
scrollToIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
gridMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
bufferItems,
|
||||
index: this.scrollToIndex,
|
||||
beforeHeight: 0,
|
||||
headerHeight: 0,
|
||||
|
|
@ -86,60 +94,126 @@ export default Vue.extend({
|
|||
return this.tableHeight > 0
|
||||
},
|
||||
|
||||
// Items to render before and after the visible area
|
||||
bufferItems() {
|
||||
if (this.gridMode) {
|
||||
return this.columnCount
|
||||
}
|
||||
return 3
|
||||
},
|
||||
|
||||
itemHeight() {
|
||||
// Align with css in FilesListVirtual
|
||||
// 138px + 44px (name) + 15px (grid gap)
|
||||
return this.gridMode ? (160 + 44 + 15) : 56
|
||||
},
|
||||
// Grid mode only
|
||||
itemWidth() {
|
||||
// 160px + 15px grid gap
|
||||
return 160 + 15
|
||||
},
|
||||
|
||||
rowCount() {
|
||||
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
|
||||
},
|
||||
columnCount() {
|
||||
if (!this.gridMode) {
|
||||
return 1
|
||||
}
|
||||
return Math.floor(this.filesListWidth / this.itemWidth)
|
||||
},
|
||||
|
||||
startIndex() {
|
||||
return Math.max(0, this.index - bufferItems)
|
||||
return Math.max(0, this.index - this.bufferItems)
|
||||
},
|
||||
shownItems() {
|
||||
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
|
||||
// If in grid mode, we need to multiply the number of rows by the number of columns
|
||||
if (this.gridMode) {
|
||||
return this.rowCount * this.columnCount
|
||||
}
|
||||
|
||||
return this.rowCount
|
||||
},
|
||||
renderedItems(): (File | Folder)[] {
|
||||
renderedItems(): RecycledPoolItem[] {
|
||||
if (!this.isReady) {
|
||||
return []
|
||||
}
|
||||
return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
|
||||
|
||||
const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[]
|
||||
|
||||
const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey]))
|
||||
const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string)
|
||||
const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key]))
|
||||
|
||||
return items.map(item => {
|
||||
const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey])
|
||||
// If defined, let's keep the key
|
||||
if (index !== -1) {
|
||||
return {
|
||||
key: Object.keys(this.$_recycledPool)[index],
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
// Get and consume reusable key or generate a new one
|
||||
const key = unusedKeys.pop() || Math.random().toString(36).substr(2)
|
||||
this.$_recycledPool[key] = item[this.dataKey]
|
||||
return { key, item }
|
||||
})
|
||||
},
|
||||
|
||||
tbodyStyle() {
|
||||
const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
|
||||
const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
|
||||
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
|
||||
const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
|
||||
const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
|
||||
return {
|
||||
paddingTop: `${this.startIndex * this.itemHeight}px`,
|
||||
paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
|
||||
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
scrollToIndex() {
|
||||
this.index = this.scrollToIndex
|
||||
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
|
||||
scrollToIndex(index) {
|
||||
this.scrollTo(index)
|
||||
},
|
||||
columnCount(columnCount, oldColumnCount) {
|
||||
if (oldColumnCount === 0) {
|
||||
// We're initializing, the scroll position
|
||||
// is handled on mounted
|
||||
console.debug('VirtualList: columnCount is 0, skipping scroll')
|
||||
return
|
||||
}
|
||||
// If the column count changes in grid view,
|
||||
// update the scroll position again
|
||||
this.scrollTo(this.index)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const before = this.$refs?.before as HTMLElement
|
||||
const root = this.$el as HTMLElement
|
||||
const tfoot = this.$refs?.tfoot as HTMLElement
|
||||
const thead = this.$refs?.thead as HTMLElement
|
||||
|
||||
this.resizeObserver = new ResizeObserver(debounce(() => {
|
||||
this.beforeHeight = before?.clientHeight ?? 0
|
||||
this.headerHeight = thead?.clientHeight ?? 0
|
||||
this.tableHeight = root?.clientHeight ?? 0
|
||||
logger.debug('VirtualList resizeObserver updated')
|
||||
logger.debug('VirtualList: resizeObserver updated')
|
||||
this.onScroll()
|
||||
}, 100, false))
|
||||
|
||||
this.resizeObserver.observe(before)
|
||||
this.resizeObserver.observe(root)
|
||||
this.resizeObserver.observe(tfoot)
|
||||
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)
|
||||
|
||||
if (this.scrollToIndex) {
|
||||
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
|
||||
}
|
||||
this.$_recycledPool = {} as Record<string, any>
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
|
|
@ -149,9 +223,19 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
methods: {
|
||||
scrollTo(index: number) {
|
||||
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
|
||||
},
|
||||
|
||||
onScroll() {
|
||||
const topScroll = this.$el.scrollTop - this.beforeHeight
|
||||
const index = Math.floor(topScroll / this.itemHeight) * this.columnCount
|
||||
// Max 0 to prevent negative index
|
||||
this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
|
||||
this.index = Math.max(0, index)
|
||||
this.$emit('scroll')
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default Vue.extend({
|
|||
filesListWidth: null as number | null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
mounted() {
|
||||
const fileListEl = document.querySelector('#app-content-vue')
|
||||
this.$resizeObserver = new ResizeObserver((entries) => {
|
||||
if (entries.length > 0 && entries[0].target === fileListEl) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const userConfig = loadState('files', 'config', {
|
|||
show_hidden: false,
|
||||
crop_image_previews: true,
|
||||
sort_favorites_first: true,
|
||||
grid_view: false,
|
||||
}) as UserConfig
|
||||
|
||||
export const useUserConfigStore = function(...args) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@
|
|||
</template>
|
||||
</BreadCrumbs>
|
||||
|
||||
<NcButton v-if="filesListWidth >= 512"
|
||||
:aria-label="gridViewButtonLabel"
|
||||
:title="gridViewButtonLabel"
|
||||
class="files-list__header-grid-button"
|
||||
type="tertiary"
|
||||
@click="toggleGridView">
|
||||
<template #icon>
|
||||
<ListViewIcon v-if="userConfig.grid_view" />
|
||||
<ViewGridIcon v-else />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<!-- Secondary loading indicator -->
|
||||
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
|
||||
</div>
|
||||
|
|
@ -99,13 +111,15 @@ import { Type } from '@nextcloud/sharing'
|
|||
import { UploadPicker } from '@nextcloud/upload'
|
||||
import Vue from 'vue'
|
||||
|
||||
import LinkIcon from 'vue-material-design-icons/Link.vue'
|
||||
import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
|
||||
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import LinkIcon from 'vue-material-design-icons/Link.vue'
|
||||
import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue'
|
||||
import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
|
||||
|
||||
import { action as sidebarAction } from '../actions/sidebarAction.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
|
|
@ -116,6 +130,7 @@ import { useUserConfigStore } from '../store/userconfig.ts'
|
|||
import { useViewConfigStore } from '../store/viewConfig.ts'
|
||||
import BreadCrumbs from '../components/BreadCrumbs.vue'
|
||||
import FilesListVirtual from '../components/FilesListVirtual.vue'
|
||||
import filesListWidthMixin from '../mixins/filesListWidth.ts'
|
||||
import filesSortingMixin from '../mixins/filesSorting.ts'
|
||||
import logger from '../logger.js'
|
||||
|
||||
|
|
@ -128,6 +143,7 @@ export default Vue.extend({
|
|||
BreadCrumbs,
|
||||
FilesListVirtual,
|
||||
LinkIcon,
|
||||
ListViewIcon,
|
||||
NcAppContent,
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
|
|
@ -135,9 +151,11 @@ export default Vue.extend({
|
|||
NcLoadingIcon,
|
||||
ShareVariantIcon,
|
||||
UploadPicker,
|
||||
ViewGridIcon,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
filesListWidthMixin,
|
||||
filesSortingMixin,
|
||||
],
|
||||
|
||||
|
|
@ -296,6 +314,12 @@ export default Vue.extend({
|
|||
return Type.SHARE_TYPE_USER
|
||||
},
|
||||
|
||||
gridViewButtonLabel() {
|
||||
return this.userConfig.grid_view
|
||||
? this.t('files', 'Switch to list view')
|
||||
: this.t('files', 'Switch to grid view')
|
||||
},
|
||||
|
||||
canUpload() {
|
||||
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
|
||||
},
|
||||
|
|
@ -430,6 +454,10 @@ export default Vue.extend({
|
|||
sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
|
||||
},
|
||||
|
||||
toggleGridView() {
|
||||
this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
|
||||
},
|
||||
|
||||
t: translate,
|
||||
n: translatePlural,
|
||||
},
|
||||
|
|
@ -452,7 +480,7 @@ $navigationToggleSize: 50px;
|
|||
.files-list {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
// Do not grow or shrink (vertically)
|
||||
flex: 0 0;
|
||||
// Align with the navigation toggle icon
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
@update:checked="setConfig('crop_image_previews', $event)">
|
||||
{{ t('files', 'Crop image previews') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch :checked="userConfig.grid_view"
|
||||
@update:checked="setConfig('grid_view', $event)">
|
||||
{{ t('files', 'Enable the grid view') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<!-- Settings API-->
|
||||
|
|
|
|||
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue