aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2025-01-18 23:27:53 +0100
committerFerdinand Thiessen <opensource@fthiessen.de>2025-01-18 23:27:53 +0100
commitbd15613bc428fcfb3893b8044171059ffdba1457 (patch)
tree909e26f6039e15aa0a403cd1f337f17132002b77 /apps/files/src
parent0d3edd28b17acb59c1ea512cad5db5ea85444813 (diff)
downloadnextcloud-server-fix/proper-preview-icon.tar.gz
nextcloud-server-fix/proper-preview-icon.zip
fix(files): Always show a fallback for the file previewfix/proper-preview-icon
If there is a blur hash, show it. If not we show the default icon until the proper preview is loaded. This makes the UI feel a bit more snappy. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps/files/src')
-rw-r--r--apps/files/src/components/FileEntry/BlurHash.vue55
-rw-r--r--apps/files/src/components/FileEntry/FileEntryPreview.vue162
-rw-r--r--apps/files/src/components/FilesListVirtual.vue9
-rw-r--r--apps/files/src/composables/usePreview.spec.ts99
-rw-r--r--apps/files/src/composables/usePreview.ts112
-rw-r--r--apps/files/src/utils/imagePreload.ts32
6 files changed, 341 insertions, 128 deletions
diff --git a/apps/files/src/components/FileEntry/BlurHash.vue b/apps/files/src/components/FileEntry/BlurHash.vue
new file mode 100644
index 00000000000..64a232943aa
--- /dev/null
+++ b/apps/files/src/components/FileEntry/BlurHash.vue
@@ -0,0 +1,55 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import { useResizeObserver } from '@vueuse/core'
+import { decode } from 'blurhash'
+import { onMounted, ref, watch } from 'vue'
+import logger from '../../logger'
+
+const props = defineProps<{
+ hash: string,
+}>()
+
+const canvas = ref<HTMLCanvasElement>()
+
+// Draw initial version on mounted
+onMounted(drawBlurHash)
+
+// On resize we redraw the BlurHash
+useResizeObserver(canvas, drawBlurHash)
+
+// Redraw when hash has changed
+watch(() => props.hash, drawBlurHash)
+
+/**
+ * Render the BlurHash within the canvas
+ */
+function drawBlurHash() {
+ if (canvas.value === undefined) {
+ // Should never happen but better safe than sorry
+ logger.error('BlurHash canvas not available')
+ return
+ }
+
+ const { height, width } = canvas.value
+ const pixels = decode(props.hash, width, height)
+
+ const ctx = canvas.value.getContext('2d')
+ if (ctx === null) {
+ logger.error('Cannot create context for BlurHash canvas')
+ return
+ }
+
+ const imageData = ctx.createImageData(width, height)
+ imageData.data.set(pixels)
+ ctx.clearRect(0, 0, width, height)
+ ctx.putImageData(imageData, 0, 0)
+}
+</script>
+
+<template>
+ <canvas ref="canvas" aria-hidden="true" />
+</template>
diff --git a/apps/files/src/components/FileEntry/FileEntryPreview.vue b/apps/files/src/components/FileEntry/FileEntryPreview.vue
index 2d5844f851f..6975babdb71 100644
--- a/apps/files/src/components/FileEntry/FileEntryPreview.vue
+++ b/apps/files/src/components/FileEntry/FileEntryPreview.vue
@@ -14,24 +14,18 @@
</template>
</template>
- <!-- Decorative images, should not be aria documented -->
- <span v-else-if="previewUrl" class="files-list__row-icon-preview-container">
- <canvas v-if="hasBlurhash && (backgroundFailed === true || !backgroundLoaded)"
- ref="canvas"
+ <span v-else class="files-list__row-icon-preview-container">
+ <BlurHash v-if="blurHash && showBlurHash"
class="files-list__row-icon-blurhash"
- aria-hidden="true" />
- <img v-if="backgroundFailed !== true"
- ref="previewImg"
+ :hash="blurHash" />
+
+ <img v-else-if="previewUrl && showPreview"
alt=""
class="files-list__row-icon-preview"
- :class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
- loading="lazy"
- :src="previewUrl"
- @error="onBackgroundError"
- @load="onBackgroundLoad">
- </span>
+ :src="previewUrl.href">
- <FileIcon v-else v-once />
+ <FileIcon v-else v-once />
+ </span>
<!-- Favorite icon -->
<span v-if="isFavorite" class="files-list__row-icon-favorite">
@@ -46,15 +40,12 @@
<script lang="ts">
import type { PropType } from 'vue'
-import type { UserConfig } from '../../types.ts'
import { Node, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-import { generateUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
-import { decode } from 'blurhash'
-import { defineComponent } from 'vue'
+import { computed, defineComponent } from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
@@ -67,12 +58,13 @@ import NetworkIcon from 'vue-material-design-icons/Network.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import PlayCircleIcon from 'vue-material-design-icons/PlayCircle.vue'
+import BlurHash from './BlurHash.vue'
import CollectivesIcon from './CollectivesIcon.vue'
import FavoriteIcon from './FavoriteIcon.vue'
import { isLivePhoto } from '../../services/LivePhotos'
import { useUserConfigStore } from '../../store/userconfig.ts'
-import logger from '../../logger.ts'
+import { usePreviewUrl } from '../../composables/usePreview.ts'
export default defineComponent({
name: 'FileEntryPreview',
@@ -80,6 +72,7 @@ export default defineComponent({
components: {
AccountGroupIcon,
AccountPlusIcon,
+ BlurHash,
CollectivesIcon,
FavoriteIcon,
FileIcon,
@@ -106,23 +99,24 @@ export default defineComponent({
},
},
- setup() {
+ setup(props) {
const userConfigStore = useUserConfigStore()
const isPublic = isPublicShare()
const publicSharingToken = getSharingToken()
- return {
- userConfigStore,
+ // Options of the preview URL
+ // This needs to be reactive to react to user config changes
+ // as well as changing of the grid mode
+ const previewSettings = computed(() => ({
+ cropPreview: userConfigStore.userConfig.crop_image_previews,
+ mimeFallback: true,
+ size: props.gridMode ? 128 : 32,
+ }))
+ return {
isPublic,
publicSharingToken,
- }
- },
-
- data() {
- return {
- backgroundFailed: undefined as boolean | undefined,
- backgroundLoaded: false,
+ ...usePreviewUrl(() => props.source, previewSettings),
}
},
@@ -131,52 +125,6 @@ export default defineComponent({
return this.source.attributes.favorite === 1
},
- userConfig(): UserConfig {
- return this.userConfigStore.userConfig
- },
- cropPreviews(): boolean {
- return this.userConfig.crop_image_previews === true
- },
-
- previewUrl() {
- if (this.source.type === FileType.Folder) {
- return null
- }
-
- if (this.backgroundFailed === true) {
- return null
- }
-
- try {
- const previewUrl = this.source.attributes.previewUrl
- || (this.isPublic
- ? generateUrl('/apps/files_sharing/publicpreview/{token}?file={file}', {
- token: this.publicSharingToken,
- file: this.source.path,
- })
- : generateUrl('/core/preview?fileId={fileid}', {
- fileid: String(this.source.fileid),
- })
- )
- const url = new URL(window.location.origin + previewUrl)
-
- // Request tiny previews
- url.searchParams.set('x', this.gridMode ? '128' : '32')
- url.searchParams.set('y', this.gridMode ? '128' : '32')
- url.searchParams.set('mimeFallback', 'true')
-
- // Etag to force refresh preview on change
- const etag = this.source?.attributes?.etag || ''
- url.searchParams.set('v', etag.slice(0, 6))
-
- // Handle cropping
- url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
- return url.href
- } catch (e) {
- return null
- }
- },
-
fileOverlay() {
if (isLivePhoto(this.source)) {
return PlayCircleIcon
@@ -226,60 +174,30 @@ export default defineComponent({
return null
},
- hasBlurhash() {
- return this.source.attributes['metadata-blurhash'] !== undefined
+ blurHash(): string | undefined {
+ return this.source.attributes['metadata-blurhash']
},
- },
- mounted() {
- if (this.hasBlurhash && this.$refs.canvas) {
- this.drawBlurhash()
- }
+ /**
+ * If true the blue hash is shown instead of the preview
+ */
+ showBlurHash(): boolean {
+ return !!this.blurHash && !this.showPreview
+ },
+
+ /**
+ * If true the preview is shown,
+ * meaning a preview is available and loaded without errors
+ */
+ showPreview(): boolean {
+ return this.previewUrl !== null && this.previewLoaded
+ },
},
methods: {
// Called from FileEntry
reset() {
- // Reset background state to cancel any ongoing requests
- this.backgroundFailed = undefined
- this.backgroundLoaded = false
- const previewImg = this.$refs.previewImg as HTMLImageElement | undefined
- if (previewImg) {
- previewImg.src = ''
- }
- },
-
- onBackgroundLoad() {
- this.backgroundFailed = false
- this.backgroundLoaded = true
- },
-
- onBackgroundError(event) {
- // Do not fail if we just reset the background
- if (event.target?.src === '') {
- return
- }
- this.backgroundFailed = true
- this.backgroundLoaded = false
- },
-
- drawBlurhash() {
- const canvas = this.$refs.canvas as HTMLCanvasElement
-
- const width = canvas.width
- const height = canvas.height
-
- const pixels = decode(this.source.attributes['metadata-blurhash'], width, height)
-
- const ctx = canvas.getContext('2d')
- if (ctx === null) {
- logger.error('Cannot create context for blurhash canvas')
- return
- }
-
- const imageData = ctx.createImageData(width, height)
- imageData.data.set(pixels)
- ctx.putImageData(imageData, 0, 0)
+ this.stopPreview()
},
t,
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 6df059f6143..4b72bf1d777 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -693,18 +693,15 @@ export default defineComponent({
}
&-preview {
+ // Ensure the fallback icon is main text color
+ color: var(--color-main-text);
+
// Center and contain the preview
object-fit: contain;
object-position: center;
height: 100%;
width: 100%;
-
- /* Preview not loaded animation effect */
- &:not(.files-list__row-icon-preview--loaded) {
- background: var(--color-loading-dark);
- // animation: preview-gradient-fade 1.2s ease-in-out infinite;
- }
}
&-favorite {
diff --git a/apps/files/src/composables/usePreview.spec.ts b/apps/files/src/composables/usePreview.spec.ts
new file mode 100644
index 00000000000..803ca0af3be
--- /dev/null
+++ b/apps/files/src/composables/usePreview.spec.ts
@@ -0,0 +1,99 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { getPreviewUrl, usePreviewUrl } from './usePreview.ts'
+import { File } from '@nextcloud/files'
+import Vue, { h, toRef } from 'vue'
+import { shallowMount } from '@vue/test-utils'
+
+describe('preview composable', () => {
+ const createData = (path: string, mime: string) => ({
+ owner: null,
+ source: `http://example.com/dav/${path}`,
+ mime,
+ mtime: new Date(),
+ root: '/',
+ })
+
+ describe('previewUrl', () => {
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
+ afterAll(() => {
+ vi.useRealTimers()
+ })
+
+ it('is reactive', async () => {
+ const text = new File({
+ ...createData('text.txt', 'text/plain'),
+ id: 1,
+ })
+ const image = new File({
+ ...createData('image.png', 'image/png'),
+ id: 2,
+ })
+
+ const wrapper = shallowMount(Vue.extend({
+ props: ['node'],
+ setup(props) {
+ const { previewUrl } = usePreviewUrl(toRef(props, 'node'))
+ return () => h('div', previewUrl.value?.href)
+ },
+ }), {
+ propsData: { node: text },
+ })
+
+ expect(wrapper.text()).toMatch('/core/preview?fileId=1')
+ await wrapper.setProps({ node: image })
+ expect(wrapper.text()).toMatch('/core/preview?fileId=2')
+ })
+
+ it('uses etag for cache busting', () => {
+ const previewNode = new File({
+ ...createData('tst.txt', 'text/plain'),
+ attributes: {
+ etag: 'etag12345',
+ },
+ })
+
+ const { previewUrl } = usePreviewUrl(previewNode)
+ expect(previewUrl.value?.searchParams.get('v')).toBe('etag12')
+ })
+
+ it('uses Nodes previewUrl if available', () => {
+ const previewNode = new File({
+ ...createData('text.txt', 'text/plain'),
+ attributes: {
+ previewUrl: '/preview.svg',
+ },
+ })
+ const { previewUrl } = usePreviewUrl(previewNode)
+
+ expect(previewUrl.value?.pathname).toBe('/preview.svg')
+ })
+
+ it('works with full URL previewUrl', () => {
+ const previewNode = new File({
+ ...createData('text.txt', 'text/plain'),
+ attributes: {
+ previewUrl: 'http://example.com/preview.svg',
+ },
+ })
+ const { previewUrl } = usePreviewUrl(previewNode)
+
+ expect(previewUrl.value?.href.startsWith('http://example.com/preview.svg?')).toBe(true)
+ })
+
+ it('supports options', () => {
+ const previewNode = new File(createData('text.txt', 'text/plain'))
+
+ expect(getPreviewUrl(previewNode, { size: 16 })?.searchParams.get('x')).toBe('16')
+ expect(getPreviewUrl(previewNode, { size: 16 })?.searchParams.get('y')).toBe('16')
+
+ expect(getPreviewUrl(previewNode, { mimeFallback: false })?.searchParams.get('mimeFallback')).toBe('false')
+ })
+ })
+})
diff --git a/apps/files/src/composables/usePreview.ts b/apps/files/src/composables/usePreview.ts
new file mode 100644
index 00000000000..0d7cd0d7b39
--- /dev/null
+++ b/apps/files/src/composables/usePreview.ts
@@ -0,0 +1,112 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+import type { MaybeRef, MaybeRefOrGetter } from '@vueuse/core'
+import type { CancelablePromise } from 'cancelable-promise'
+
+import { generateUrl } from '@nextcloud/router'
+import { toValue } from '@vueuse/core'
+import { ref, watchEffect } from 'vue'
+import { preloadImage } from '../utils/imagePreload'
+import logger from '../logger'
+
+interface PreviewOptions {
+ /**
+ * Size of the previews in px
+ * @default 32
+ */
+ size?: number
+ /**
+ * Should the preview fall back to the mime type icon
+ * @default true
+ */
+ mimeFallback?: boolean
+ /**
+ * Should the preview be cropped or fitted
+ * @default false (meaning it gets fitted)
+ */
+ cropPreview?: boolean
+}
+
+/**
+ * Generate the preview URL of a file node
+ *
+ * @param node The node to generate the preview for
+ * @param options Preview options
+ */
+export function getPreviewUrl(node: Node, options: PreviewOptions = {}): URL {
+ options = { size: 32, cropPreview: false, mimeFallback: true, ...options }
+
+ const previewUrl = node.attributes?.previewUrl
+ || generateUrl('/core/preview?fileId={fileid}', {
+ fileid: node.fileid,
+ })
+
+ let url: URL
+ try {
+ url = new URL(previewUrl)
+ } catch (e) {
+ url = new URL(previewUrl, window.location.origin)
+ }
+
+ // Request preview with params
+ url.searchParams.set('x', `${options.size}`)
+ url.searchParams.set('y', `${options.size}`)
+ url.searchParams.set('mimeFallback', `${options.mimeFallback}`)
+
+ // Handle cropping
+ url.searchParams.set('a', options.cropPreview === true ? '0' : '1')
+
+ // Etag to force refresh preview on change
+ const etag = node.attributes?.etag || ''
+ url.searchParams.set('v', etag.slice(0, 6))
+
+ return url
+}
+
+/**
+ * Get and pre-load a the preview of a node.
+ *
+ * @param node The node to get the preview for
+ * @param options Preview options
+ */
+export function usePreviewUrl(node: MaybeRefOrGetter<Node>, options?: MaybeRef<PreviewOptions>) {
+ const previewUrl = ref<URL|null>(null)
+ const previewLoaded = ref(false)
+ const promise = ref<CancelablePromise<boolean>>()
+
+ /**
+ * Stop the preview loading
+ */
+ function stopPreview() {
+ promise.value?.cancel()
+ }
+
+ watchEffect(() => {
+ try {
+ previewUrl.value = getPreviewUrl(toValue(node), toValue(options || {}))
+ } catch (error) {
+ // this can happen if the Node object was invalid
+ // so lets be safe here
+ logger.error('Failed to generate preview URL for node', { error, node })
+ return
+ } finally {
+ previewLoaded.value = false
+ }
+
+ // Preload the image
+ promise.value = preloadImage(previewUrl.value.href)
+ promise.value.then((success: boolean) => {
+ previewLoaded.value = success
+ })
+ })
+
+ return {
+ previewUrl,
+ previewLoaded,
+ stopPreview,
+ }
+}
diff --git a/apps/files/src/utils/imagePreload.ts b/apps/files/src/utils/imagePreload.ts
new file mode 100644
index 00000000000..9822fa30693
--- /dev/null
+++ b/apps/files/src/utils/imagePreload.ts
@@ -0,0 +1,32 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { CancelablePromise } from 'cancelable-promise'
+import PQueue from 'p-queue'
+
+const queue = new PQueue({ concurrency: 5 })
+
+/**
+ * Preload an image URL
+ * @param url URL of the image
+ */
+export function preloadImage(url: string): CancelablePromise<boolean> {
+ const { resolve, promise } = Promise.withResolvers<boolean>()
+ const image = new Image()
+
+ queue.add(() => {
+ image.onerror = () => resolve(false)
+ image.onload = () => resolve(true)
+ image.src = url
+ return promise
+ })
+
+ return Object.assign(promise, {
+ cancel: () => {
+ image.src = ''
+ resolve(false)
+ },
+ }) as CancelablePromise<boolean>
+}