feat(components): [image-viewer] add error slot (#21961)

* feat(components): [image-viewer] add custom failed content

* Update packages/components/image-viewer/src/image-viewer.vue

Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com>

* feat: rename error slot

* test: add custom load failed slot tests for Image and ImageViewer

* docs: perf dome

* fix: update v

* feat: add activeIndex and src properties

* fix: add key binding to img element for better reactivity

* fix: keep original structure

* fix: restore error source in image load-failed example

* feat: add image preview

* refactor: remove unused var

* fix: update demo

* chore: better contrast for dark mode

---------

Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com>
Co-authored-by: Dsaquel <291874700n@gmail.com>
This commit is contained in:
jiaxiang
2025-09-17 23:46:35 +08:00
committed by GitHub
parent 8371deaa5d
commit 0ff86060ab
7 changed files with 155 additions and 62 deletions

View File

@@ -25,8 +25,7 @@ image/placeholder
## Load Failed
:::demo Custom failed content when error occurs to image load by `slot = error`
:::demo Custom failed content when error occurs to image load by `slot = error` and `slot = viewer-error`.
image/load-failed
:::
@@ -118,13 +117,11 @@ image/custom-progress
### Image Slots
| Name | Description | Type |
| ----------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| placeholder | custom placeholder content when image hasn't loaded yet. | - |
| error | custom image load failed content. | - |
| viewer | custom content when image preview. | - |
| progress ^(2.9.4) | custom progress content when image preview. (Priority is higher than `show-progress` prop) | ^[object]`{ activeIndex: number, total: number }` |
| toolbar ^(2.9.4) | custom toolbar content when image preview. | ^[object]`{actions: (action: ImageViewerAction, options?: ImageViewerActionOptions ) => void, prev: ()=> void, next: () => void,reset: () => void, activeIndex: number }, setActiveItem: (index: number) => void` |
| Name | Description | Type |
| ----------------------------------------- | --------------------------------------------------------------------- | ---- |
| placeholder | custom placeholder content when image hasn't loaded yet. | - |
| error | custom image load failed content. | - |
| [image viewer slots](#image-viewer-slots) | when you allow big image preview, image viewer slots all can be used. | - |
### Image Exposes
@@ -156,9 +153,19 @@ image/custom-progress
| Name | Description | Type |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------ |
| close | trigger when clicking on close button or when `hide-on-click-modal` enabled clicking on backdrop. | ^[Function]`() => void` |
| error ^(2.11.3) | same as native error. | ^[Function]`(e: Event) => void` |
| switch | trigger when switching images. | ^[Function]`(index: number) => void` |
| rotate ^(2.3.13) | trigger when rotating images. | ^[Function]`(deg: number) => void` |
### Image Viewer Slots
| Name | Description | Type |
| ---------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| viewer | custom content | - |
| progress ^(2.9.4) | custom progress content (Priority is higher than `show-progress` prop) | ^[object]`{ activeIndex: number, total: number }` |
| toolbar ^(2.9.4) | custom toolbar content | ^[object]`{actions: (action: ImageViewerAction, options?: ImageViewerActionOptions ) => void, prev: ()=> void, next: () => void,reset: () => void, activeIndex: number }, setActiveItem: (index: number) => void` |
| viewer-error ^(2.11.3) | custom image load failed content. | ^[object]`{ activeIndex: number, src: string }` |
### Image Viewer Exposes
| Name | Description | Type |

View File

@@ -1,61 +1,82 @@
<template>
<div class="demo-image__error">
<div class="block">
<span class="demonstration">Default</span>
<el-image />
</div>
<div class="block">
<span class="demonstration">Custom</span>
<el-image>
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
</div>
<div class="demo-image__error" flex gap-2>
<el-image />
<el-image>
<template #error>
<div class="image-viewer-slot image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<el-image :src="url" :preview-src-list="srcList" show-progress>
<template #viewer-error="{ activeIndex, src }">
<div class="image-slot viewer-error">
<el-icon><icon-picture /></el-icon>
<span>
this is viewer-error slot. current index: {{ activeIndex }}. src:
{{ src }}
</span>
</div>
</template>
</el-image>
<el-button @click="showPreview = true"> preview controlled </el-button>
<el-image-viewer
v-if="showPreview"
show-progress
:url-list="srcList"
@close="showPreview = false"
>
<template #viewer-error="{ activeIndex, src }">
<div class="image-slot viewer-error">
<el-icon><icon-picture /></el-icon>
<span>
this is viewer-error slot. current index: {{ activeIndex }}. src:
{{ src }}
</span>
</div>
</template>
</el-image-viewer>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Picture as IconPicture } from '@element-plus/icons-vue'
const showPreview = ref(false)
const srcList = [
'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
'https://errorSrc',
]
const url =
'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg'
</script>
<style scoped>
.demo-image__error .block {
padding: 30px 0;
text-align: center;
border-right: solid 1px var(--el-border-color);
display: inline-block;
width: 49%;
box-sizing: border-box;
vertical-align: top;
}
.demo-image__error .demonstration {
display: block;
color: var(--el-text-color-secondary);
font-size: 14px;
margin-bottom: 20px;
}
.demo-image__error .el-image {
padding: 0 5px;
max-width: 300px;
max-height: 200px;
width: 100%;
height: 200px;
}
.demo-image__error .image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
flex-direction: column;
font-size: 30px;
height: 200px;
background: #fff;
}
.demo-image__error .image-slot .el-icon {
font-size: 30px;
}
.image-viewer-slot {
background: var(--el-fill-color-light);
}
.viewer-error {
color: #000;
}
</style>

View File

@@ -104,4 +104,24 @@ describe('<image-viewer />', () => {
expect(innerText).toBe('1 / 2')
wrapper.unmount()
})
test('custom ImageViewer load failed slot', async () => {
const wrapper = mount(ImageViewer, {
props: {
urlList: [IMAGE_SUCCESS],
initialIndex: 1,
},
slots: {
'viewer-error': () => (
<div class="load-failed-slot">load failed slot</div>
),
},
})
await doubleWait()
const img = wrapper.find('.el-image-viewer__wrapper img')
await img.trigger('error')
await doubleWait()
expect(wrapper.find('.load-failed-slot').exists()).toBe(true)
})
})

View File

@@ -103,6 +103,7 @@ export type ImageViewerPropsPublic = __ExtractPublicPropTypes<
export const imageViewerEmits = {
close: () => true,
error: (evt: Event) => evt instanceof Event,
switch: (index: number) => isNumber(index),
rotate: (deg: number) => isNumber(deg),
}

View File

@@ -83,19 +83,24 @@
</div>
<!-- CANVAS -->
<div :class="ns.e('canvas')">
<template v-for="(url, i) in urlList" :key="i">
<img
v-if="i === activeIndex"
:ref="(el) => (imgRefs[i] = el as HTMLImageElement)"
:src="url"
:style="imgStyle"
:class="ns.e('img')"
:crossorigin="crossorigin"
@load="handleImgLoad"
@error="handleImgError"
@mousedown="handleMouseDown"
/>
</template>
<slot
v-if="loadError && $slots['viewer-error']"
name="viewer-error"
:active-index="activeIndex"
:src="currentImg"
/>
<img
v-else
ref="imgRef"
:key="currentImg"
:src="currentImg"
:style="imgStyle"
:class="ns.e('img')"
:crossorigin="crossorigin"
@load="handleImgLoad"
@error="handleImgError"
@mousedown="handleMouseDown"
/>
</div>
<slot />
</el-focus-trap>
@@ -164,7 +169,7 @@ const { t } = useLocale()
const ns = useNamespace('image-viewer')
const { nextZIndex } = useZIndex()
const wrapper = ref<HTMLDivElement>()
const imgRefs = ref<HTMLImageElement[]>([])
const imgRef = ref<HTMLImageElement>()
const scopeEventListener = effectScope()
@@ -174,6 +179,7 @@ const scaleClamped = computed(() => {
})
const loading = ref(true)
const loadError = ref(false)
const activeIndex = ref(props.initialIndex)
const mode = shallowRef<ImageViewerMode>(modes.CONTAIN)
const transform = ref({
@@ -292,7 +298,9 @@ function handleImgLoad() {
}
function handleImgError(e: Event) {
loadError.value = true
loading.value = false
emit('error', e)
;(e.target as HTMLImageElement).alt = t('el.image.error')
}
@@ -330,7 +338,7 @@ function reset() {
}
function toggleMode() {
if (loading.value) return
if (loading.value || loadError.value) return
const modeNames = keysOf(modes)
const modeValues = Object.values(modes)
@@ -342,6 +350,7 @@ function toggleMode() {
}
function setActiveItem(index: number) {
loadError.value = false
const len = props.urlList.length
activeIndex.value = (index + len) % len
}
@@ -357,7 +366,7 @@ function next() {
}
function handleActions(action: ImageViewerAction, options = {}) {
if (loading.value) return
if (loading.value || loadError.value) return
const { minScale, maxScale } = props
const { zoomRate, rotateDeg, enableTransition } = {
zoomRate: props.zoomRate,
@@ -425,7 +434,7 @@ watch(
watch(currentImg, () => {
nextTick(() => {
const $img = imgRefs.value[0]
const $img = imgRef.value
if (!$img?.complete) {
loading.value = true
}

View File

@@ -229,6 +229,38 @@ describe('Image.vue', () => {
expect(wrapper.find('.el-image-viewer__progress').exists()).toBe(true)
})
test('custom viewer load failed slot', async () => {
const url = IMAGE_SUCCESS
const srcList = ['error']
const wrapper = _mount(
`
<el-image
ref="imageRef"
:src="url"
:preview-src-list="srcList"
>
<template #viewer-error>
<div class="load-failed-slot">
load failed slot
</div>
</template>
</el-image>`,
() => ({
url,
srcList,
})
)
await doubleWait()
wrapper.vm.$refs.imageRef.showPreview()
await doubleWait()
const img = wrapper.find('.el-image-viewer__canvas img')
await img.trigger('error')
await doubleWait()
expect(wrapper.find('.load-failed-slot').exists()).toBe(true)
})
describe('load', () => {
mockImageEvent()

View File

@@ -50,6 +50,9 @@
<template #toolbar="toolbar">
<slot name="toolbar" v-bind="toolbar" />
</template>
<template v-if="$slots['viewer-error']" #viewer-error="viewerError">
<slot name="viewer-error" v-bind="viewerError" />
</template>
</image-viewer>
</template>
</div>