diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-01-18 23:27:53 +0100 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2025-01-18 23:27:53 +0100 |
commit | bd15613bc428fcfb3893b8044171059ffdba1457 (patch) | |
tree | 909e26f6039e15aa0a403cd1f337f17132002b77 /apps/files/src | |
parent | 0d3edd28b17acb59c1ea512cad5db5ea85444813 (diff) | |
download | nextcloud-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.vue | 55 | ||||
-rw-r--r-- | apps/files/src/components/FileEntry/FileEntryPreview.vue | 162 | ||||
-rw-r--r-- | apps/files/src/components/FilesListVirtual.vue | 9 | ||||
-rw-r--r-- | apps/files/src/composables/usePreview.spec.ts | 99 | ||||
-rw-r--r-- | apps/files/src/composables/usePreview.ts | 112 | ||||
-rw-r--r-- | apps/files/src/utils/imagePreload.ts | 32 |
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> +} |