feat(files): grid view

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ 2023-10-13 16:49:54 +02:00
parent 694fd51cba
commit 16975ae457
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
8 changed files with 604 additions and 66 deletions

View file

@ -71,7 +71,7 @@
: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
@ -80,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
@ -170,6 +170,10 @@ export default Vue.extend({
type: Number,
default: 0,
},
compact: {
type: Boolean,
default: false,
},
},
setup() {
@ -200,7 +204,7 @@ export default Vue.extend({
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
if (this.filesListWidth < 512 || this.compact) {
return []
}
return this.currentView?.columns || []
@ -513,27 +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);
}
}
</style>
<style>
/* @keyframes preview-gradient-fade {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
} */
</style>

View file

@ -105,6 +105,10 @@ export default Vue.extend({
type: Boolean,
default: false,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
@ -137,7 +141,7 @@ export default Vue.extend({
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768) {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
@ -145,7 +149,7 @@ export default Vue.extend({
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.visible) {
if (!this.visible || this.gridMode) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')

View file

@ -23,7 +23,6 @@
<!-- 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">
@ -98,6 +97,10 @@ export default Vue.extend({
type: Object as PropType<Node>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {

View file

@ -99,6 +99,10 @@ export default Vue.extend({
type: Boolean,
default: false,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
@ -146,8 +150,8 @@ export default Vue.extend({
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', '32')
url.searchParams.set('y', '32')
url.searchParams.set('x', this.gridMode ? '128' : '32')
url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping

View 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>

View file

@ -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>

View file

@ -31,7 +31,7 @@
:data-component="FileEntry"
:data-key="'source'"
:data-sources="nodes"
:item-height="56"
:grid-mode="false"
:extra-props="{
isMtimeAvailable,
isSizeAvailable,
@ -90,7 +90,7 @@ import Vue from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import DragAndDropNotice from './DragAndDropNotice.vue'
import FileEntry from './FileEntry.vue'
import FileEntry from './FileEntryGrid.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
@ -302,6 +302,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 +348,7 @@ export default Vue.extend({
user-select: none;
border-bottom: 1px solid var(--color-border);
user-select: none;
height: var(--row-height);
}
td, th {
@ -485,8 +494,8 @@ export default Vue.extend({
// Folder overlay
&-overlay {
position: absolute;
max-height: 18px;
max-width: 18px;
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;
@ -533,6 +542,8 @@ export default Vue.extend({
.files-list__row-name-ext {
color: var(--color-text-maxcontrast);
// always show the extension
overflow: visible;
}
}
@ -556,6 +567,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
@ -596,3 +608,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>

View file

@ -11,7 +11,10 @@
</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"
@ -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,18 @@
</template>
<script lang="ts">
import { File, Folder, debounce } from 'debounce'
import Vue from 'vue'
import logger from '../logger.js'
import type { File, Folder } from '@nextcloud/files'
import { debounce } from 'debounce'
import Vue, { PropType } from 'vue'
// Items to render before and after the visible area
const bufferItems = 3
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger.js'
export default Vue.extend({
name: 'VirtualList',
mixins: [filesListWidthMixin],
props: {
dataComponent: {
type: [Object, Function],
@ -52,26 +56,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,11 +89,44 @@ 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() {
// 160px + 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
},
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)[] {
if (!this.isReady) {
@ -100,11 +136,11 @@ export default Vue.extend({
},
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`,
}
},
@ -119,7 +155,6 @@ export default Vue.extend({
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(() => {
@ -132,13 +167,12 @@ export default Vue.extend({
this.resizeObserver.observe(before)
this.resizeObserver.observe(root)
this.resizeObserver.observe(tfoot)
this.resizeObserver.observe(thead)
this.$el.addEventListener('scroll', this.onScroll)
if (this.scrollToIndex) {
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
this.$el.scrollTop = Math.floor((this.index * this.itemHeight) / this.rowCount) + this.beforeHeight
}
},
@ -151,7 +185,7 @@ export default Vue.extend({
methods: {
onScroll() {
// 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, Math.floor(Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight) * this.columnCount))
this.$emit('scroll')
},
},