mirror of
https://github.com/nextcloud/server.git
synced 2026-06-05 23:06:48 -04:00
fix: improved preview handling
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
This commit is contained in:
parent
a66cae02ef
commit
014a57e541
8 changed files with 287 additions and 65 deletions
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
167
apps/files/src/components/FilesListNotVirtual.vue
Normal file
167
apps/files/src/components/FilesListNotVirtual.vue
Normal file
|
|
@ -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: {
|
||||
|
|
|
|||
37
apps/files/src/services/PreviewService.ts
Normal file
37
apps/files/src/services/PreviewService.ts
Normal file
|
|
@ -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…
Reference in a new issue