diff options
author | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-04 08:10:43 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@protonmail.com> | 2023-04-06 14:49:32 +0200 |
commit | 014a57e54174ce9a8f2c55beafad2dd8d1c6a9d0 (patch) | |
tree | 79507dfdb6fd3b9cb8662f458a9353af0a8592c4 /apps/files | |
parent | a66cae02efcc27d962d867ba9a9e5da0441333e5 (diff) | |
download | nextcloud-server-014a57e54174ce9a8f2c55beafad2dd8d1c6a9d0.tar.gz nextcloud-server-014a57e54174ce9a8f2c55beafad2dd8d1c6a9d0.zip |
fix: improved preview handling
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r-- | apps/files/src/components/BreadCrumbs.vue | 1 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry.vue | 102 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeader.vue | 16 | ||||
-rw-r--r-- | apps/files/src/components/FilesListHeaderButton.vue | 12 | ||||
-rw-r--r-- | apps/files/src/components/FilesListNotVirtual.vue | 167 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 15 | ||||
-rw-r--r-- | apps/files/src/services/PreviewService.ts | 37 |
7 files changed, 286 insertions, 64 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index dcedeab0172..d2f8610e9ca 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -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'))] diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 71a0c4e2a65..a4b373a7d9d 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -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> diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 184ca7aa30e..f0af8c531dc 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -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, }, }) diff --git a/apps/files/src/components/FilesListHeaderButton.vue b/apps/files/src/components/FilesListHeaderButton.vue index cde77ff21fe..fc9b7330956 100644 --- a/apps/files/src/components/FilesListHeaderButton.vue +++ b/apps/files/src/components/FilesListHeaderButton.vue @@ -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, }, }) diff --git a/apps/files/src/components/FilesListNotVirtual.vue b/apps/files/src/components/FilesListNotVirtual.vue new file mode 100644 index 00000000000..edfb2bd820a --- /dev/null +++ b/apps/files/src/components/FilesListNotVirtual.vue @@ -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> diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index e6cd60c2cad..7891128a1eb 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -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: { diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts new file mode 100644 index 00000000000..840d6a48afa --- /dev/null +++ b/apps/files/src/services/PreviewService.ts @@ -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 + }) + }) +} |