aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-04 08:10:43 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-06 14:49:32 +0200
commit014a57e54174ce9a8f2c55beafad2dd8d1c6a9d0 (patch)
tree79507dfdb6fd3b9cb8662f458a9353af0a8592c4 /apps/files
parenta66cae02efcc27d962d867ba9a9e5da0441333e5 (diff)
downloadnextcloud-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.vue1
-rw-r--r--apps/files/src/components/FileEntry.vue102
-rw-r--r--apps/files/src/components/FilesListHeader.vue16
-rw-r--r--apps/files/src/components/FilesListHeaderButton.vue12
-rw-r--r--apps/files/src/components/FilesListNotVirtual.vue167
-rw-r--r--apps/files/src/components/FilesListVirtual.vue15
-rw-r--r--apps/files/src/services/PreviewService.ts37
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
+ })
+ })
+}