Skip to content

Commit 9c25a15

Browse files
authored
Merge pull request #6396 from nextcloud-libraries/feat/nc-blur-hash
feat(NcBlurHash): Add a blur hash component
2 parents b41998f + 0ee2517 commit 9c25a15

File tree

9 files changed

+370
-2
lines changed

9 files changed

+370
-2
lines changed

jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const ignorePatterns = [
1919
'markdown-table', // ESM dependency of remark-gfm
2020
'mdast-util-*',
2121
'micromark',
22+
'p-queue',
23+
'p-timeout',
2224
'property-information',
2325
'rehype-*',
2426
'remark-*',

package-lock.json

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"@nextcloud/vue-select": "^3.25.1",
9797
"@vueuse/components": "^11.0.0",
9898
"@vueuse/core": "^11.0.0",
99+
"blurhash": "^2.0.5",
99100
"clone": "^2.1.2",
100101
"debounce": "^2.2.0",
101102
"dompurify": "^3.2.4",
@@ -105,6 +106,7 @@
105106
"focus-trap": "^7.4.3",
106107
"linkify-string": "^4.0.0",
107108
"md5": "^2.3.0",
109+
"p-queue": "^8.0.1",
108110
"rehype-external-links": "^3.0.0",
109111
"rehype-highlight": "^7.0.2",
110112
"rehype-react": "^7.1.2",
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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>

src/components/NcBlurHash/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
export { default } from './NcBlurHash.vue'

src/components/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export { default as NcAppSettingsSection } from './NcAppSettingsSection/index.js
3333
export { default as NcAppSidebar } from './NcAppSidebar/index.js'
3434
export { default as NcAppSidebarTab } from './NcAppSidebarTab/index.js'
3535
export { default as NcAvatar } from './NcAvatar/index.js'
36+
export { default as NcBlurHash } from './NcBlurHash/index.js'
3637
export { default as NcBreadcrumb } from './NcBreadcrumb/index.js'
3738
export { default as NcBreadcrumbs } from './NcBreadcrumbs/index.js'
3839
export { default as NcButton } from './NcButton/index.js'

src/functions/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
*/
55

66
export * from './a11y/index.ts'
7+
export * from './contactsMenu/index.ts'
78
export * from './dialog/index.ts'
89
export * from './emoji/index.ts'
9-
export * from './reference/index.js'
1010
export * from './isDarkTheme/index.ts'
11-
export * from './contactsMenu/index.ts'
11+
export * from './preloadImage/index.ts'
12+
export * from './reference/index.js'
1213
export { default as usernameToColor } from './usernameToColor/index.js'

0 commit comments

Comments
 (0)