Skip to content

Commit

Permalink
fixup! allow image preloading
Browse files Browse the repository at this point in the history
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jan 21, 2025
1 parent ba2973c commit a255c43
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 9 deletions.
35 changes: 35 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
158 changes: 151 additions & 7 deletions src/components/NcBlurHash/NcBlurHash.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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;
Expand All @@ -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({
/**
Expand All @@ -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')
Expand All @@ -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>
5 changes: 3 additions & 2 deletions src/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
25 changes: 25 additions & 0 deletions src/functions/preloadImage/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
])
Expand Down

0 comments on commit a255c43

Please sign in to comment.