Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-01-21 12:08:08 +01:00
Родитель ba2973cbf3
Коммит 42a22dda4e
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 45FAE7268762B400
7 изменённых файлов: 246 добавлений и 9 удалений

Просмотреть файл

@ -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
package-lock.json сгенерированный
Просмотреть файл

@ -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',
])