|
| 1 | +<!-- |
| 2 | + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors |
| 3 | + - SPDX-License-Identifier: AGPL-3.0-or-later |
| 4 | +--> |
| 5 | + |
| 6 | +<docs> |
| 7 | +## BlurHash |
| 8 | + |
| 9 | +A [blur hash](https://blurha.sh/) is a very compact representation of an image, |
| 10 | +that can be used as a placeholder until the image was fully loaded. |
| 11 | + |
| 12 | +### Image placeholder |
| 13 | + |
| 14 | +The default use case is as a placeholder that is transferred in initial state, |
| 15 | +while the real image will be fetched from the network. |
| 16 | +In this case the image source can be passed to the component. |
| 17 | +The component will immediately start to preload it, |
| 18 | +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. |
| 19 | + |
| 20 | +```vue |
| 21 | + <template> |
| 22 | + <div class="wrapper"> |
| 23 | + <NcBlurHash class="shown-image" |
| 24 | + :hash="blurHash" |
| 25 | + :src="imageSource" |
| 26 | + @load="onLoaded" /> |
| 27 | + |
| 28 | + <NcButton @click="toggleImage"> |
| 29 | + {{ |
| 30 | + loading |
| 31 | + ? 'Loading...' |
| 32 | + : (loaded ? 'Unload image' : 'Load image') |
| 33 | + }} |
| 34 | + </NcButton> |
| 35 | + </div> |
| 36 | + </template> |
| 37 | + <script> |
| 38 | + export default { |
| 39 | + data() { |
| 40 | + return { |
| 41 | + loaded: false, |
| 42 | + loading: false, |
| 43 | + blurHash: 'M8CR]OkDD%kD9ZtRayofaykC00ay$_ay~T', |
| 44 | + } |
| 45 | + }, |
| 46 | + computed: { |
| 47 | + // This is cheating but we can not emulate slow network connection |
| 48 | + // so imagine that this means the source becomes loaded |
| 49 | + imageSource() { |
| 50 | + return this.loaded |
| 51 | + ? 'favicon-touch.png' |
| 52 | + : 'invalid-file-that-will-never-load.png' |
| 53 | + }, |
| 54 | + }, |
| 55 | + methods: { |
| 56 | + toggleImage() { |
| 57 | + if (this.loaded) { |
| 58 | + this.loaded = false |
| 59 | + this.loading = false |
| 60 | + } else { |
| 61 | + // emulate slow network |
| 62 | + this.loading = true |
| 63 | + window.setTimeout(() => { |
| 64 | + this.loaded = !this.loaded |
| 65 | + this.loading = false |
| 66 | + }, 3000) |
| 67 | + } |
| 68 | + }, |
| 69 | + |
| 70 | + // you could use `success` here (boolean) to decide if the image is loaded or failed |
| 71 | + onLoaded(success) { |
| 72 | + // ... |
| 73 | + }, |
| 74 | + }, |
| 75 | + } |
| 76 | + </script> |
| 77 | + <style scoped> |
| 78 | + .wrapper { |
| 79 | + display: flex; |
| 80 | + flex-direction: row; |
| 81 | + align-items: center; |
| 82 | + gap: 12px; |
| 83 | + } |
| 84 | + |
| 85 | + .shown-image { |
| 86 | + width: 150px; |
| 87 | + height: 150px; |
| 88 | + border-radius: 24px; |
| 89 | + } |
| 90 | + </style> |
| 91 | +``` |
| 92 | + |
| 93 | +### Manual usage as a placeholder |
| 94 | + |
| 95 | +Using `v-if` is also possible, this can e.g. used if the image is not loaded from an URL. |
| 96 | + |
| 97 | +```vue |
| 98 | + <template> |
| 99 | + <div class="wrapper"> |
| 100 | + <img :class="loaded ? 'shown-image' : 'hidden-visually'" |
| 101 | + alt="" |
| 102 | + src="favicon-touch.png"> |
| 103 | + <NcBlurHash v-if="!loaded" |
| 104 | + class="shown-image" |
| 105 | + :hash="blurHash" /> |
| 106 | + <NcButton @click="loaded = !loaded">Toggle blur hash</NcButton> |
| 107 | + </div> |
| 108 | + </template> |
| 109 | + <script> |
| 110 | + export default { |
| 111 | + data() { |
| 112 | + return { |
| 113 | + loaded: false, |
| 114 | + blurHash: 'M8CR]OkDD%kD9ZtRayofaykC00ay$_ay~T', |
| 115 | + } |
| 116 | + }, |
| 117 | + } |
| 118 | + </script> |
| 119 | + <style scoped> |
| 120 | + .wrapper { |
| 121 | + display: flex; |
| 122 | + flex-direction: row; |
| 123 | + align-items: center; |
| 124 | + gap: 12px; |
| 125 | + } |
| 126 | + |
| 127 | + .shown-image { |
| 128 | + width: 150px; |
| 129 | + height: 150px; |
| 130 | + border-radius: 24px; |
| 131 | + } |
| 132 | + </style> |
| 133 | +``` |
| 134 | +</docs> |
| 135 | + |
| 136 | +<script setup> |
| 137 | +import { decode } from 'blurhash' |
| 138 | +import { ref, watch, nextTick } from 'vue' |
| 139 | +import { logger } from '../../utils/logger.ts' |
| 140 | +import { preloadImage } from '../../functions/preloadImage/index.ts' |
| 141 | +
|
| 142 | +const props = defineProps({ |
| 143 | + /** |
| 144 | + * The blur hash value to use. |
| 145 | + */ |
| 146 | + hash: { |
| 147 | + required: true, |
| 148 | + type: String, |
| 149 | + }, |
| 150 | +
|
| 151 | + /** |
| 152 | + * This is normally not needed, but if this blur hash is not only intended |
| 153 | + * for decorative purpose, descriptive text should be passed for accessibility. |
| 154 | + */ |
| 155 | + alt: { |
| 156 | + type: String, |
| 157 | + default: '', |
| 158 | + }, |
| 159 | +
|
| 160 | + /** |
| 161 | + * Optional an image source to load, during the load the blur hash is shown. |
| 162 | + * As soon as it is loaded the image will be shown instead. |
| 163 | + */ |
| 164 | + src: { |
| 165 | + type: String, |
| 166 | + default: '', |
| 167 | + }, |
| 168 | +}) |
| 169 | +
|
| 170 | +const emit = defineEmits([ |
| 171 | + /** |
| 172 | + * Emitted when the image (`src`) has been loaded. |
| 173 | + */ |
| 174 | + 'load', |
| 175 | +]) |
| 176 | +
|
| 177 | +const canvas = ref() |
| 178 | +const imageLoaded = ref(false) |
| 179 | +
|
| 180 | +// Redraw when hash has changed |
| 181 | +watch(() => props.hash, drawBlurHash) |
| 182 | +// Redraw if image loaded again - also draw immediate on mount |
| 183 | +watch(imageLoaded, () => { |
| 184 | + if (imageLoaded.value === false) { |
| 185 | + // We need to wait one tick to make sure the canvas is in the DOM |
| 186 | + nextTick(() => drawBlurHash()) |
| 187 | + } |
| 188 | +}, { immediate: true }) |
| 189 | +
|
| 190 | +// Preload image on source change |
| 191 | +watch(() => props.src, () => { |
| 192 | + imageLoaded.value = false |
| 193 | + if (props.src) { |
| 194 | + preloadImage(props.src) |
| 195 | + .then((success) => { |
| 196 | + imageLoaded.value = success |
| 197 | + emit('load', success) |
| 198 | + }) |
| 199 | + } |
| 200 | +}, { immediate: true }) |
| 201 | +
|
| 202 | +/** |
| 203 | + * Render the BlurHash within the canvas |
| 204 | + */ |
| 205 | +function drawBlurHash() { |
| 206 | + if (imageLoaded.value) { |
| 207 | + // skip |
| 208 | + return |
| 209 | + } |
| 210 | +
|
| 211 | + if (!props.hash) { |
| 212 | + logger.error('Invalid BlurHash value') |
| 213 | + return |
| 214 | + } |
| 215 | +
|
| 216 | + if (canvas.value === undefined) { |
| 217 | + // Should never happen but better safe than sorry |
| 218 | + logger.error('BlurHash canvas not available') |
| 219 | + return |
| 220 | + } |
| 221 | +
|
| 222 | + const { height, width } = canvas.value |
| 223 | + const pixels = decode(props.hash, width, height) |
| 224 | +
|
| 225 | + const ctx = canvas.value.getContext('2d') |
| 226 | + if (ctx === null) { |
| 227 | + logger.error('Cannot create context for BlurHash canvas') |
| 228 | + return |
| 229 | + } |
| 230 | +
|
| 231 | + const imageData = ctx.createImageData(width, height) |
| 232 | + imageData.data.set(pixels) |
| 233 | + ctx.putImageData(imageData, 0, 0) |
| 234 | +} |
| 235 | +</script> |
| 236 | + |
| 237 | +<template> |
| 238 | + <Transition :css="src ? undefined : false" |
| 239 | + :enter-active-class="$style.fadeTransition" |
| 240 | + :leave-active-class="$style.fadeTransition" |
| 241 | + :enter-class="$style.fadeTransitionActive" |
| 242 | + :leave-to-class="$style.fadeTransitionActive"> |
| 243 | + <canvas v-if="!imageLoaded" |
| 244 | + ref="canvas" |
| 245 | + :aria-hidden="alt ? null : 'true'" |
| 246 | + :aria-label="alt" /> |
| 247 | + <img v-else :alt="alt" :src="src"> |
| 248 | + </Transition> |
| 249 | +</template> |
| 250 | + |
| 251 | +<style module> |
| 252 | +.fadeTransition { |
| 253 | + transition: all var(--animation-quick) ease; |
| 254 | +} |
| 255 | +
|
| 256 | +.fadeTransitionActive { |
| 257 | + opacity: 0; |
| 258 | + position: absolute; |
| 259 | +} |
| 260 | +</style> |
0 commit comments