fixup! allow image preloading
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Родитель
ba2973cbf3
Коммит
42a22dda4e
|
@ -19,6 +19,8 @@ const ignorePatterns = [
|
|||
'markdown-table', // ESM dependency of remark-gfm
|
||||
'mdast-util-*',
|
||||
'micromark',
|
||||
'p-queue',
|
||||
'p-timeout',
|
||||
'property-information',
|
||||
'rehype-*',
|
||||
'remark-*',
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"focus-trap": "^7.4.3",
|
||||
"linkify-string": "^4.0.0",
|
||||
"md5": "^2.3.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"rehype-react": "^7.1.2",
|
||||
|
@ -21870,6 +21871,28 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.0.1.tgz",
|
||||
"integrity": "sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"p-timeout": "^6.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||
|
@ -21884,6 +21907,18 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-timeout": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
|
||||
"integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
|
|
|
@ -106,6 +106,7 @@
|
|||
"focus-trap": "^7.4.3",
|
||||
"linkify-string": "^4.0.0",
|
||||
"md5": "^2.3.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-highlight": "^7.0.1",
|
||||
"rehype-react": "^7.1.2",
|
||||
|
|
|
@ -9,8 +9,91 @@
|
|||
A [blur hash](https://blurha.sh/) is a very compact representation of an image,
|
||||
that can be used as a placeholder until the image was fully loaded.
|
||||
|
||||
### Image placeholder
|
||||
|
||||
The default use case is as a placeholder that is transferred in initial state,
|
||||
while the real image will be fetched from the network.
|
||||
In this case the image source can be passed to the component.
|
||||
The component will immediately start to preload it,
|
||||
as soon as it is loaded the blur hash will be swapped with the real image and this component will behave like an `<a>`-element.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<NcBlurHash class="shown-image"
|
||||
:hash="blurHash"
|
||||
:src="imageSource"
|
||||
@load="onLoaded" />
|
||||
|
||||
<NcButton @click="toggleImage">
|
||||
{{
|
||||
loading
|
||||
? 'Loading...'
|
||||
: (loaded ? 'Unload image' : 'Load image')
|
||||
}}
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
blurHash: 'M8CR]OkDD%kD9ZtRayofaykC00ay$_ay~T',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// This is cheating but we can not emulate slow network connection
|
||||
// so imagine that this means the source becomes loaded
|
||||
imageSource() {
|
||||
return this.loaded
|
||||
? 'favicon-touch.png'
|
||||
: 'invalid-file-that-will-never-load.png'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleImage() {
|
||||
if (this.loaded) {
|
||||
this.loaded = false
|
||||
this.loading = false
|
||||
} else {
|
||||
// emulate slow network
|
||||
this.loading = true
|
||||
window.setTimeout(() => {
|
||||
this.loaded = !this.loaded
|
||||
this.loading = false
|
||||
}, 3000)
|
||||
}
|
||||
},
|
||||
|
||||
// you could use `success` here (boolean) to decide if the image is loaded or failed
|
||||
onLoaded(success) {
|
||||
// ...
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shown-image {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Manual usage as a placeholder
|
||||
|
||||
Using `v-if` is also possible, this can e.g. used if the image is not loaded from an URL.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
|
@ -40,6 +123,7 @@ that can be used as a placeholder until the image was fully loaded.
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shown-image {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
|
@ -51,8 +135,9 @@ that can be used as a placeholder until the image was fully loaded.
|
|||
|
||||
<script setup>
|
||||
import { decode } from 'blurhash'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { logger } from '../../utils/logger.ts'
|
||||
import { preloadImage } from '../../functions/preloadImage/index.ts'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
|
@ -67,28 +152,67 @@ const props = defineProps({
|
|||
* This is normally not needed, but if this blur hash is not only intended
|
||||
* for decorative purpose, descriptive text should be passed for accessibility.
|
||||
*/
|
||||
label: {
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
* Optional an image source to load, during the load the blur hash is shown.
|
||||
* As soon as it is loaded the image will be shown instead.
|
||||
*/
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const canvas = ref()
|
||||
const emit = defineEmits([
|
||||
/**
|
||||
* Emitted when the image (`src`) has been loaded.
|
||||
*/
|
||||
'load',
|
||||
])
|
||||
|
||||
// Draw initial version on mounted
|
||||
onMounted(drawBlurHash)
|
||||
const canvas = ref()
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// Redraw when hash has changed
|
||||
watch(() => props.hash, drawBlurHash)
|
||||
// Redraw if image loaded again - also draw immediate on mount
|
||||
watch(imageLoaded, () => {
|
||||
if (imageLoaded.value === false) {
|
||||
// We need to wait one tick to make sure the canvas is in the DOM
|
||||
nextTick(() => drawBlurHash())
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Preload image on source change
|
||||
watch(() => props.src, () => {
|
||||
imageLoaded.value = false
|
||||
if (props.src) {
|
||||
preloadImage(props.src)
|
||||
.then((success) => {
|
||||
imageLoaded.value = success
|
||||
emit('load', success)
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* Render the BlurHash within the canvas
|
||||
*/
|
||||
function drawBlurHash() {
|
||||
if (imageLoaded.value) {
|
||||
// skip
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.hash) {
|
||||
logger.error('Invalid BlurHash value')
|
||||
return
|
||||
}
|
||||
|
||||
if (canvas.value === undefined) {
|
||||
// Should never happen but better safe than sorry
|
||||
logger.error('BlurHash canvas not available')
|
||||
|
@ -106,11 +230,31 @@ function drawBlurHash() {
|
|||
|
||||
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="label ? null : 'true'" :aria-label="label" />
|
||||
<Transition :css="src ? undefined : false"
|
||||
:enter-active-class="$style.fadeTransition"
|
||||
:leave-active-class="$style.fadeTransition"
|
||||
:enter-class="$style.fadeTransitionActive"
|
||||
:leave-to-class="$style.fadeTransitionActive">
|
||||
<canvas v-if="!imageLoaded"
|
||||
ref="canvas"
|
||||
:aria-hidden="alt ? null : 'true'"
|
||||
:aria-label="alt" />
|
||||
<img v-else :alt="alt" :src="src">
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.fadeTransition {
|
||||
transition: all var(--animation-quick) ease;
|
||||
}
|
||||
|
||||
.fadeTransitionActive {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,9 +4,10 @@
|
|||
*/
|
||||
|
||||
export * from './a11y/index.ts'
|
||||
export * from './contactsMenu/index.ts'
|
||||
export * from './dialog/index.ts'
|
||||
export * from './emoji/index.ts'
|
||||
export * from './reference/index.js'
|
||||
export * from './isDarkTheme/index.ts'
|
||||
export * from './contactsMenu/index.ts'
|
||||
export * from './preloadImage/index.ts'
|
||||
export * from './reference/index.js'
|
||||
export { default as usernameToColor } from './usernameToColor/index.js'
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
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): Promise<boolean> {
|
||||
const { resolve, promise } = Promise.withResolvers<boolean>()
|
||||
queue.add(() => {
|
||||
const image = new Image()
|
||||
image.onerror = () => resolve(false)
|
||||
image.onload = () => resolve(true)
|
||||
image.src = url
|
||||
return promise
|
||||
})
|
||||
|
||||
return promise
|
||||
}
|
|
@ -79,6 +79,35 @@ webpackRules.RULE_SCSS = {
|
|||
],
|
||||
}
|
||||
|
||||
webpackRules.RULE_CSS = {
|
||||
test: /\.css$/,
|
||||
oneOf: [
|
||||
{
|
||||
resourceQuery: /module/,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
// Same as in Vite
|
||||
localIdentName: '_[local]_[hash:base64:5]',
|
||||
},
|
||||
},
|
||||
},
|
||||
'resolve-url-loader',
|
||||
],
|
||||
},
|
||||
{
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader',
|
||||
'resolve-url-loader',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
webpackRules.RULE_JS.exclude = BabelLoaderExcludeNodeModulesExcept([
|
||||
'tributejs',
|
||||
])
|
||||
|
|
Загрузка…
Ссылка в новой задаче