fix: improved preview handling

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/36534/head
John Molakvoæ 1 year ago
parent a66cae02ef
commit 014a57e541
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -40,6 +40,7 @@ export default Vue.extend({
computed: {
dirs() {
const cumulativePath = (acc) => (value) => (acc += `${value}/`)
// Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
const paths = this.path.split('/').filter(Boolean).map(cumulativePath('/'))
// Strip away trailing slash
return ['/', ...paths.map(path => path.replace(/^(.+)\/$/, '$1'))]

@ -32,7 +32,7 @@
<!-- Link to file -->
<td class="files-list__row-name">
<a v-bind="linkTo">
<a ref="name" v-bind="linkTo">
<!-- Icon or preview -->
<span class="files-list__row-icon">
<FolderIcon v-if="source.type === 'folder'" />
@ -61,7 +61,8 @@
<!-- TODO: implement CustomElementRender -->
<!-- Menu actions -->
<NcActions ref="actionsMenu"
<NcActions v-if="active"
ref="actionsMenu"
:force-title="true"
:inline="enabledInlineActions.length">
<NcActionButton v-for="action in enabledMenuActions"
@ -99,10 +100,9 @@
<script lang='ts'>
import { debounce } from 'debounce'
import { Folder, File, getFileActions, formatFileSize } from '@nextcloud/files'
import { Folder, File, formatFileSize } from '@nextcloud/files'
import { Fragment } from 'vue-fragment'
import { join } from 'path'
import { mapState } from 'pinia'
import { showError } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import FileIcon from 'vue-material-design-icons/File.vue'
@ -113,17 +113,15 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadi
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import Vue from 'vue'
import { isCachedPreview } from '../services/PreviewService'
import { getFileActions } from '../services/FileAction'
import { useFilesStore } from '../store/files'
import { UserConfig } from '../types'
import { useSelectionStore } from '../store/selection'
import { useUserConfigStore } from '../store/userconfig'
import CustomElementRender from './CustomElementRender.vue'
import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
import { UserConfig } from '../types'
// The preview service worker cache name (see webpack config)
const SWCacheName = 'previews'
// The registered actions list
const actions = getFileActions()
@ -156,6 +154,10 @@ export default Vue.extend({
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
setup() {
@ -314,6 +316,7 @@ export default Vue.extend({
// Restore default tabindex
this.$el.parentNode.style.display = ''
},
/**
* When the source changes, reset the preview
* and fetch the new one.
@ -335,11 +338,7 @@ export default Vue.extend({
this.fetchAndApplyPreview()
}, 150, false)
// Init img on mount and
// not when the module is imported to
// avoid sharing between recycled components
this.img = null
// Fetch the preview on init
this.debounceIfNotCached()
},
@ -354,7 +353,7 @@ export default Vue.extend({
}
// Check if we already have this preview cached
const isCached = await this.isCachedPreview(this.previewUrl)
const isCached = await isCachedPreview(this.previewUrl)
if (isCached) {
this.backgroundImage = `url(${this.previewUrl})`
this.backgroundFailed = false
@ -372,19 +371,37 @@ export default Vue.extend({
}
// If any image is being processed, reset it
if (this.img) {
if (this.previewPromise) {
this.clearImg()
}
this.img = new Image()
this.img.fetchpriority = this.active ? 'high' : 'auto'
this.img.onload = () => {
this.backgroundImage = `url(${this.previewUrl})`
}
this.img.onerror = () => {
this.backgroundFailed = true
}
this.img.src = this.previewUrl
// Ensure max 5 previews are being fetched at the same time
const controller = new AbortController()
// Store the promise to be able to cancel it
this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
const img = new Image()
// If active, load the preview with higher priority
img.fetchpriority = this.active ? 'high' : 'auto'
img.onload = () => {
this.backgroundImage = `url(${this.previewUrl})`
this.backgroundFailed = false
resolve(img)
}
img.onerror = () => {
this.backgroundFailed = true
reject(img)
}
img.src = this.previewUrl
// Image loading has been canceled
onCancel(() => {
img.onerror = null
img.onload = null
img.src = ''
controller.abort()
})
})
},
resetState() {
@ -402,23 +419,10 @@ export default Vue.extend({
this.backgroundImage = ''
this.backgroundFailed = false
if (this.img) {
// Do not fail on cancel
this.img.onerror = null
this.img.src = ''
if (this.previewPromise) {
this.previewPromise.cancel()
this.previewPromise = null
}
this.img = null
},
isCachedPreview(previewUrl) {
return caches.open(SWCacheName)
.then(function(cache) {
return cache.match(previewUrl)
.then(function(response) {
return !!response // or `return response ? true : false`, or similar.
})
})
},
hashCode(str) {
@ -464,23 +468,21 @@ tr {
/* Preview not loaded animation effect */
.files-list__row-icon-preview:not([style*='background']) {
background: linear-gradient(110deg, var(--color-loading-dark) 0%, var(--color-loading-dark) 25%, var(--color-loading-light) 50%, var(--color-loading-dark) 75%, var(--color-loading-dark) 100%);
background-size: 400%;
animation: preview-gradient-slide 1.2s ease-in-out infinite;
background: var(--color-loading-dark);
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
}
</style>
<style>
@keyframes preview-gradient-slide {
/* @keyframes preview-gradient-fade {
0% {
background-position: 100% 0%;
opacity: 1;
}
50% {
background-position: 0% 0%;
opacity: 0.5;
}
/* adds a small delay to the animation */
100% {
background-position: 0% 0%;
opacity: 1;
}
}
} */
</style>

@ -88,6 +88,12 @@ export default Vue.extend({
FilesListHeaderActions,
},
provide() {
return {
toggleSortBy: this.toggleSortBy,
}
},
props: {
isSizeAvailable: {
type: Boolean,
@ -186,6 +192,16 @@ export default Vue.extend({
}
},
toggleSortBy(key) {
// If we're already sorting by this key, flip the direction
if (this.sortingMode === key) {
this.sortingStore.toggleSortingDirection(this.currentView.id)
return
}
// else sort ASC by this new key
this.sortingStore.setSortingBy(key, this.currentView.id)
},
t: translate,
},
})

@ -51,6 +51,8 @@ export default Vue.extend({
NcButton,
},
inject: ['toggleSortBy'],
props: {
name: {
type: String,
@ -97,16 +99,6 @@ export default Vue.extend({
})
},
toggleSortBy(key) {
// If we're already sorting by this key, flip the direction
if (this.sortingMode === key) {
this.sortingStore.toggleSortingDirection(this.currentView.id)
return
}
// else sort ASC by this new key
this.sortingStore.setSortingBy(key, this.currentView.id)
},
t: translate,
},
})

@ -0,0 +1,167 @@
<!--
- @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
-
- @author Gary Kim <gary@garykim.dev>
-
- @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>
<table class="files-list">
<!-- Accessibility description -->
<caption class="hidden-visually">
{{ currentView.caption || '' }}
{{ t('files', 'This list is not fully rendered for performances reasons. The files will be rendered as you navigate through the list.') }}
</caption>
<!-- Header-->
<thead>
<FilesListHeader :is-size-available="isSizeAvailable" :nodes="nodes" />
</thead>
<!-- Body-->
<tbody class="files-list__body">
<tr v-for="item in nodes"
:key="item.source"
class="files-list__row">
<FileEntry :active="true"
:is-size-available="isSizeAvailable"
:source="item" />
</tr>
</tbody>
<!-- Footer-->
<tfoot>
<FilesListFooter :is-size-available="isSizeAvailable" :nodes="nodes" :summary="summary" />
</tfoot>
</table>
</template>
<script lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
import FileEntry from './FileEntry.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListFooter from './FilesListFooter.vue'
export default Vue.extend({
name: 'FilesListVirtual',
components: {
RecycleScroller,
FileEntry,
FilesListHeader,
FilesListFooter,
},
props: {
currentView: {
type: Object,
required: true,
},
nodes: {
type: Array,
required: true,
},
},
data() {
return {
FileEntry,
}
},
computed: {
files() {
return this.nodes.filter(node => node.type === 'file')
},
summaryFile() {
const count = this.files.length
return translatePlural('files', '{count} file', '{count} files', count, { count })
},
summaryFolder() {
const count = this.nodes.length - this.files.length
return translatePlural('files', '{count} folder', '{count} folders', count, { count })
},
summary() {
return translate('files', '{summaryFile} and {summaryFolder}', this)
},
isSizeAvailable() {
return this.nodes.some(node => node.attributes.size !== undefined)
},
},
methods: {
getFileId(node) {
return node.attributes.fileid
},
t: translate,
},
})
</script>
<style scoped lang="scss">
.files-list {
--row-height: 55px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: 44px;
--icon-preview-size: 32px;
display: block;
overflow: auto;
height: 100%;
&::v-deep {
// Table head, body and footer
tbody, .vue-recycle-scroller__slot {
display: flex;
flex-direction: column;
width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
}
// Table header
.vue-recycle-scroller__slot[role='thead'] {
// Pinned on top when scrolling
position: sticky;
z-index: 10;
top: 0;
height: var(--row-height);
background-color: var(--color-main-background);
}
/**
* Common row styling. tr are handled by
* vue-virtual-scroller, so we need to
* have those rules in here.
*/
tr {
display: flex;
align-items: center;
width: 100%;
border-bottom: 1px solid var(--color-border);
}
}
}
</style>

@ -31,8 +31,12 @@
list-class="files-list__body"
list-tag="tbody"
role="table">
<template #default="{ item, active }">
<FileEntry :active="active" :is-size-available="isSizeAvailable" :source="item" />
<template #default="{ item, active, index }">
<!-- File row -->
<FileEntry :active="active"
:index="index"
:is-size-available="isSizeAvailable"
:source="item" />
</template>
<template #before>
@ -59,8 +63,8 @@ import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
import FileEntry from './FileEntry.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListFooter from './FilesListFooter.vue'
import FilesListHeader from './FilesListHeader.vue'
export default Vue.extend({
name: 'FilesListVirtual',
@ -88,6 +92,7 @@ export default Vue.extend({
FileEntry,
}
},
computed: {
files() {
return this.nodes.filter(node => node.type === 'file')
@ -111,7 +116,9 @@ export default Vue.extend({
mounted() {
// Make the root recycle scroller a table for proper semantics
this.$el.querySelector('.vue-recycle-scroller__slot').setAttribute('role', 'thead')
const slots = this.$el.querySelectorAll('.vue-recycle-scroller__slot')
slots[0].setAttribute('role', 'thead')
slots[1].setAttribute('role', 'tfoot')
},
methods: {

@ -0,0 +1,37 @@
/**
* @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/>.
*
*/
// The preview service worker cache name (see webpack config)
const SWCacheName = 'previews'
/**
* Check if the preview is already cached by the service worker
*/
export const isCachedPreview = function(previewUrl: string) {
return caches.open(SWCacheName)
.then(function(cache) {
return cache.match(previewUrl)
.then(function(response) {
return !!response
})
})
}

@ -53,7 +53,7 @@ const data = `<?xml version="1.0"?>
const resultToNode = function(node: FileStat): File | Folder {
const permissions = parseWebdavPermissions(node.props?.permissions)
const owner = getCurrentUser()?.uid as string
const previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}x=32&y=32', node.props)
const previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}&x=32&y=32', node.props)
const nodeData = {
id: node.props?.fileid as number || 0,

Loading…
Cancel
Save