refactor(components): [image, image-viewer] refactor (#6704)

Co-authored-by: 三咲智子 <sxzz@sxzz.moe>
This commit is contained in:
bqy_fe 2022-03-30 21:44:30 +08:00 committed by GitHub
parent 7a4fc4cefb
commit 8c26036e60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 524 additions and 576 deletions

View File

@ -47,50 +47,57 @@ image/image-preview
:::
## Image Attributes
## Image API
| Attribute | Description | Type | Accepted values | Default |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| alt | Native alt | string | - | - |
| fit | Indicate how the image should be resized to fit its container, same as [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) | string | fill / contain / cover / none / scale-down | - |
| hide-on-click-modal | When enabling preview, use this flag to control whether clicking on backdrop can exit preview mode | boolean | true / false | false |
| initial-index | The initial preview image index, less than the length of `url-list` | number | int | 0 |
| lazy | Whether to use lazy load | boolean | — | false |
| preview-src-list | allow big image preview | Array | — | - |
| referrer-policy | Native referrerPolicy | string | - | - |
| src | Image source, same as native | string | — | - |
| scroll-container | The container to add scroll listener when using lazy load | string / HTMLElement | — | The nearest parent container whose overflow property is auto or scroll |
| z-index | set image preview z-index | Number | — | 2000 |
| preview-teleported | whether to append image-viewer to body. A nested parent element attribute transform should have this attribute set to `true` | boolean | — | false |
### Image Attributes
## Image Events
| Name | Description | Type | Default |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
| `src` | image source, same as native. | `string` | — |
| `fit` | indicate how the image should be resized to fit its container, same as [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit). | `'fill' \| 'contain' \| 'cover' \| 'none' \| 'scale'-down'` | — |
| `hide-on-click-modal` | when enabling preview, use this flag to control whether clicking on backdrop can exit preview mode. | `boolean` | `false` |
| `lazy` | whether to use lazy load. | `boolean` | `false` |
| `scroll-container` | the container to add scroll listener when using lazy load. | `string \| HTMLElement` | the nearest parent container whose overflow property is auto or scroll. |
| `alt` | native attribute `alt`. | `string` | — |
| `referrer-policy` | native attribute `referrerPolicy`. | `string` | — |
| `preview-src-list` | allow big image preview. | `string[]` | — |
| `z-index` | set image preview z-index. | `number` | — |
| `initial-index` | initial preview image index, less than the length of `url-list`. | `number` | `0` |
| `preview-teleported` | whether to append image-viewer to body. A nested parent element attribute transform should have this attribute set to `true`. | `boolean` | `false` |
| Event Name | Description | Parameters |
| ---------- | -------------------- | ---------- |
| load | Same as native load | (e: Event) |
| error | Same as native error | (e: Error) |
### Image Events
## Image Slots
| Name | Description | Type |
| -------- | ------------------------------------------------------------------------------------------------- | ------------------------- |
| `load` | same as native load. | `(e: Event) => void` |
| `error` | same as native error. | `(e: Error) => void` |
| `switch` | trigger when switching images. | `(index: number) => void` |
| `close` | trigger when clicking on close button or when `hide-on-click-modal` enabled clicking on backdrop. | `() => void` |
| Name | Description |
| ----------- | ------------------------------- |
| placeholder | Triggers when image load |
| error | Triggers when image load failed |
### Image Slots
## ImageViewer Attributes
| Name | Description |
| ------------- | -------------------------------- |
| `placeholder` | triggers when image load. |
| `error` | triggers when image load failed. |
| `viewer` | description of the image. |
| Attribute | Description | Type | Acceptable Value | Default |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | ------------------- | ------- |
| url-list | Preview link list | Array\<string\> | - | [] |
| z-index | Preview backdrop z-index | number / string | int / string\<int\> | 2000 |
| initial-index | The initial preview image index, less than or equal to the length of `url-list` | number | int | 0 |
| infinite | Whether preview is infinite | boolean | true / false | true |
| hide-on-click-modal | Whether user can emit close event when clicking backdrop | boolean | true / false | false |
| teleported | whether to append image itself to body. A nested parent element attribute transform should have this attribute set to `true` | boolean | — | false |
## Image Viewer API
## ImageViewer Events
### Image Viewer Attributes
| Event name | Description | Callback parameter |
| ---------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- |
| close | Emitted when clicking on `X` button or when `hide-on-click-modal` enabled clicking on backdrop | None |
| switch | When switching images | `(val: number)` switching target index |
| Name | Description | Type | Default |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------- |
| `url-list` | preview link list. | `string[]` | `[]` |
| `z-index` | preview backdrop z-index. | `number \| string` | — |
| `initial-index` | the initial preview image index, less than or equal to the length of `url-list`. | `number` | `0` |
| `infinite` | whether preview is infinite. | `boolean` | `true` |
| `hide-on-click-modal` | whether user can emit close event when clicking backdrop. | `boolean` | `false` |
| `teleported` | whether to append image itself to body. A nested parent element attribute transform should have this attribute set to `true`. | `boolean` | `false` |
### Image Viewer Events
| Event name | Description | Type |
| ---------- | ------------------------------------------------------------------------------------------------- | ------------------------- |
| `close` | trigger when clicking on close button or when `hide-on-click-modal` enabled clicking on backdrop. | `() => void` |
| `switch` | trigger when switching images. | `(index: number) => void` |

View File

@ -1,6 +1,12 @@
import { buildProps, definePropType, mutable } from '@element-plus/utils'
import type { ExtractPropTypes } from 'vue'
export type ImageViewerAction =
| 'zoomIn'
| 'zoomOut'
| 'clockwise'
| 'anticlockwise'
export const imageViewerProps = buildProps({
urlList: {
type: definePropType<string[]>(Array),

View File

@ -11,7 +11,7 @@
<!-- CLOSE -->
<span :class="[ns.e('btn'), ns.e('close')]" @click="hide">
<el-icon><close /></el-icon>
<el-icon><Close /></el-icon>
</span>
<!-- ARROW -->
@ -24,7 +24,7 @@
]"
@click="prev"
>
<el-icon><arrow-left /></el-icon>
<el-icon><ArrowLeft /></el-icon>
</span>
<span
:class="[
@ -34,17 +34,17 @@
]"
@click="next"
>
<el-icon><arrow-right /></el-icon>
<el-icon><ArrowRight /></el-icon>
</span>
</template>
<!-- ACTIONS -->
<div :class="[ns.e('btn'), ns.e('actions')]">
<div :class="ns.e('actions__inner')">
<el-icon @click="handleActions('zoomOut')">
<zoom-out />
<ZoomOut />
</el-icon>
<el-icon @click="handleActions('zoomIn')">
<zoom-in />
<ZoomIn />
</el-icon>
<i :class="ns.e('actions__divider')" />
<el-icon @click="toggleMode">
@ -52,10 +52,10 @@
</el-icon>
<i :class="ns.e('actions__divider')" />
<el-icon @click="handleActions('anticlockwise')">
<refresh-left />
<RefreshLeft />
</el-icon>
<el-icon @click="handleActions('clockwise')">
<refresh-right />
<RefreshRight />
</el-icon>
</div>
</div>
@ -80,10 +80,9 @@
</teleport>
</template>
<script lang="ts">
<script lang="ts" setup>
import {
computed,
defineComponent,
effectScope,
markRaw,
nextTick,
@ -93,10 +92,10 @@ import {
} from 'vue'
import { isNumber, useEventListener } from '@vueuse/core'
import { throttle } from 'lodash-unified'
import ElIcon from '@element-plus/components/icon'
import { useLocale, useNamespace, useZIndex } from '@element-plus/hooks'
import { EVENT_CODE } from '@element-plus/constants'
import { isFirefox } from '@element-plus/utils'
import ElIcon from '@element-plus/components/icon'
import {
ArrowLeft,
ArrowRight,
@ -111,6 +110,7 @@ import {
import { imageViewerEmits, imageViewerProps } from './image-viewer'
import type { CSSProperties } from 'vue'
import type { ImageViewerAction } from './image-viewer'
const Mode = {
CONTAIN: {
@ -124,304 +124,262 @@ const Mode = {
}
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel'
export type ImageViewerAction =
| 'zoomIn'
| 'zoomOut'
| 'clockwise'
| 'anticlockwise'
export default defineComponent({
defineOptions({
name: 'ElImageViewer',
components: {
ElIcon,
Close,
ArrowLeft,
ArrowRight,
ZoomOut,
ZoomIn,
RefreshLeft,
RefreshRight,
},
props: imageViewerProps,
emits: imageViewerEmits,
})
setup(props, { emit }) {
const { t } = useLocale()
const ns = useNamespace('image-viewer')
const { nextZIndex } = useZIndex()
const wrapper = ref<HTMLDivElement>()
const imgRefs = ref<any[]>([])
const props = defineProps(imageViewerProps)
const emit = defineEmits(imageViewerEmits)
const scopeEventListener = effectScope()
const { t } = useLocale()
const ns = useNamespace('image-viewer')
const { nextZIndex } = useZIndex()
const wrapper = ref<HTMLDivElement>()
const imgRefs = ref<any[]>([])
const loading = ref(true)
const index = ref(props.initialIndex)
const mode = ref(Mode.CONTAIN)
const transform = ref({
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
})
const scopeEventListener = effectScope()
const isSingle = computed(() => {
const { urlList } = props
return urlList.length <= 1
})
const loading = ref(true)
const index = ref(props.initialIndex)
const mode = ref(Mode.CONTAIN)
const transform = ref({
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
})
const isFirst = computed(() => {
return index.value === 0
})
const isSingle = computed(() => {
const { urlList } = props
return urlList.length <= 1
})
const isLast = computed(() => {
return index.value === props.urlList.length - 1
})
const isFirst = computed(() => {
return index.value === 0
})
const currentImg = computed(() => {
return props.urlList[index.value]
})
const isLast = computed(() => {
return index.value === props.urlList.length - 1
})
const imgStyle = computed(() => {
const { scale, deg, offsetX, offsetY, enableTransition } = transform.value
let translateX = offsetX / scale
let translateY = offsetY / scale
const currentImg = computed(() => {
return props.urlList[index.value]
})
switch (deg % 360) {
case 90:
case -270:
;[translateX, translateY] = [translateY, -translateX]
break
case 180:
case -180:
;[translateX, translateY] = [-translateX, -translateY]
break
case 270:
case -90:
;[translateX, translateY] = [-translateY, translateX]
break
}
const imgStyle = computed(() => {
const { scale, deg, offsetX, offsetY, enableTransition } = transform.value
let translateX = offsetX / scale
let translateY = offsetY / scale
const style: CSSProperties = {
transform: `scale(${scale}) rotate(${deg}deg) translate(${translateX}px, ${translateY}px)`,
transition: enableTransition ? 'transform .3s' : '',
}
if (mode.value.name === Mode.CONTAIN.name) {
style.maxWidth = style.maxHeight = '100%'
}
return style
})
switch (deg % 360) {
case 90:
case -270:
;[translateX, translateY] = [translateY, -translateX]
break
case 180:
case -180:
;[translateX, translateY] = [-translateX, -translateY]
break
case 270:
case -90:
;[translateX, translateY] = [-translateY, translateX]
break
}
const computedZIndex = computed(() => {
return isNumber(props.zIndex) ? props.zIndex : nextZIndex()
})
const style: CSSProperties = {
transform: `scale(${scale}) rotate(${deg}deg) translate(${translateX}px, ${translateY}px)`,
transition: enableTransition ? 'transform .3s' : '',
}
if (mode.value.name === Mode.CONTAIN.name) {
style.maxWidth = style.maxHeight = '100%'
}
return style
})
function hide() {
unregisterEventListener()
emit('close')
const computedZIndex = computed(() => {
return isNumber(props.zIndex) ? props.zIndex : nextZIndex()
})
function hide() {
unregisterEventListener()
emit('close')
}
function registerEventListener() {
const keydownHandler = throttle((e: KeyboardEvent) => {
switch (e.code) {
// ESC
case EVENT_CODE.esc:
hide()
break
// SPACE
case EVENT_CODE.space:
toggleMode()
break
// LEFT_ARROW
case EVENT_CODE.left:
prev()
break
// UP_ARROW
case EVENT_CODE.up:
handleActions('zoomIn')
break
// RIGHT_ARROW
case EVENT_CODE.right:
next()
break
// DOWN_ARROW
case EVENT_CODE.down:
handleActions('zoomOut')
break
}
function registerEventListener() {
const keydownHandler = throttle((e: KeyboardEvent) => {
switch (e.code) {
// ESC
case EVENT_CODE.esc:
hide()
break
// SPACE
case EVENT_CODE.space:
toggleMode()
break
// LEFT_ARROW
case EVENT_CODE.left:
prev()
break
// UP_ARROW
case EVENT_CODE.up:
handleActions('zoomIn')
break
// RIGHT_ARROW
case EVENT_CODE.right:
next()
break
// DOWN_ARROW
case EVENT_CODE.down:
handleActions('zoomOut')
break
}
})
const mousewheelHandler = throttle(
(e: WheelEvent | any /* TODO: wheelDelta is deprecated */) => {
const delta = e.wheelDelta ? e.wheelDelta : -e.detail
if (delta > 0) {
handleActions('zoomIn', {
zoomRate: 1.2,
enableTransition: false,
})
} else {
handleActions('zoomOut', {
zoomRate: 1.2,
enableTransition: false,
})
}
}
)
scopeEventListener.run(() => {
useEventListener(document, 'keydown', keydownHandler)
useEventListener(document, mousewheelEventName, mousewheelHandler)
})
}
function unregisterEventListener() {
scopeEventListener.stop()
}
function handleImgLoad() {
loading.value = false
}
function handleImgError(e: Event) {
loading.value = false
;(e.target as HTMLImageElement).alt = t('el.image.error')
}
function handleMouseDown(e: MouseEvent) {
if (loading.value || e.button !== 0 || !wrapper.value) return
transform.value.enableTransition = false
const { offsetX, offsetY } = transform.value
const startX = e.pageX
const startY = e.pageY
const dragHandler = throttle((ev: MouseEvent) => {
transform.value = {
...transform.value,
offsetX: offsetX + ev.pageX - startX,
offsetY: offsetY + ev.pageY - startY,
}
})
const removeMousemove = useEventListener(
document,
'mousemove',
dragHandler
)
useEventListener(document, 'mouseup', () => {
removeMousemove()
})
e.preventDefault()
}
function reset() {
transform.value = {
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
})
const mousewheelHandler = throttle(
(e: WheelEvent | any /* TODO: wheelDelta is deprecated */) => {
const delta = e.wheelDelta ? e.wheelDelta : -e.detail
if (delta > 0) {
handleActions('zoomIn', {
zoomRate: 1.2,
enableTransition: false,
})
} else {
handleActions('zoomOut', {
zoomRate: 1.2,
enableTransition: false,
})
}
}
)
function toggleMode() {
if (loading.value) return
scopeEventListener.run(() => {
useEventListener(document, 'keydown', keydownHandler)
useEventListener(document, mousewheelEventName, mousewheelHandler)
})
}
const modeNames = Object.keys(Mode)
const modeValues = Object.values(Mode)
const currentMode = mode.value.name
const index = modeValues.findIndex((i) => i.name === currentMode)
const nextIndex = (index + 1) % modeNames.length
mode.value = Mode[modeNames[nextIndex]]
reset()
function unregisterEventListener() {
scopeEventListener.stop()
}
function handleImgLoad() {
loading.value = false
}
function handleImgError(e: Event) {
loading.value = false
;(e.target as HTMLImageElement).alt = t('el.image.error')
}
function handleMouseDown(e: MouseEvent) {
if (loading.value || e.button !== 0 || !wrapper.value) return
transform.value.enableTransition = false
const { offsetX, offsetY } = transform.value
const startX = e.pageX
const startY = e.pageY
const dragHandler = throttle((ev: MouseEvent) => {
transform.value = {
...transform.value,
offsetX: offsetX + ev.pageX - startX,
offsetY: offsetY + ev.pageY - startY,
}
})
const removeMousemove = useEventListener(document, 'mousemove', dragHandler)
useEventListener(document, 'mouseup', () => {
removeMousemove()
})
function prev() {
if (isFirst.value && !props.infinite) return
const len = props.urlList.length
index.value = (index.value - 1 + len) % len
}
e.preventDefault()
}
function next() {
if (isLast.value && !props.infinite) return
const len = props.urlList.length
index.value = (index.value + 1) % len
}
function reset() {
transform.value = {
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
}
}
function handleActions(action: ImageViewerAction, options = {}) {
if (loading.value) return
const { zoomRate, rotateDeg, enableTransition } = {
zoomRate: 1.4,
rotateDeg: 90,
enableTransition: true,
...options,
function toggleMode() {
if (loading.value) return
const modeNames = Object.keys(Mode)
const modeValues = Object.values(Mode)
const currentMode = mode.value.name
const index = modeValues.findIndex((i) => i.name === currentMode)
const nextIndex = (index + 1) % modeNames.length
mode.value = Mode[modeNames[nextIndex]]
reset()
}
function prev() {
if (isFirst.value && !props.infinite) return
const len = props.urlList.length
index.value = (index.value - 1 + len) % len
}
function next() {
if (isLast.value && !props.infinite) return
const len = props.urlList.length
index.value = (index.value + 1) % len
}
function handleActions(action: ImageViewerAction, options = {}) {
if (loading.value) return
const { zoomRate, rotateDeg, enableTransition } = {
zoomRate: 1.4,
rotateDeg: 90,
enableTransition: true,
...options,
}
switch (action) {
case 'zoomOut':
if (transform.value.scale > 0.2) {
transform.value.scale = Number.parseFloat(
(transform.value.scale / zoomRate).toFixed(3)
)
}
switch (action) {
case 'zoomOut':
if (transform.value.scale > 0.2) {
transform.value.scale = Number.parseFloat(
(transform.value.scale / zoomRate).toFixed(3)
)
}
break
case 'zoomIn':
if (transform.value.scale < 7) {
transform.value.scale = Number.parseFloat(
(transform.value.scale * zoomRate).toFixed(3)
)
}
break
case 'clockwise':
transform.value.deg += rotateDeg
break
case 'anticlockwise':
transform.value.deg -= rotateDeg
break
break
case 'zoomIn':
if (transform.value.scale < 7) {
transform.value.scale = Number.parseFloat(
(transform.value.scale * zoomRate).toFixed(3)
)
}
transform.value.enableTransition = enableTransition
break
case 'clockwise':
transform.value.deg += rotateDeg
break
case 'anticlockwise':
transform.value.deg -= rotateDeg
break
}
transform.value.enableTransition = enableTransition
}
watch(currentImg, () => {
nextTick(() => {
const $img = imgRefs.value[0]
if (!$img?.complete) {
loading.value = true
}
})
})
watch(currentImg, () => {
nextTick(() => {
const $img = imgRefs.value[0]
if (!$img?.complete) {
loading.value = true
}
})
})
watch(index, (val) => {
reset()
emit('switch', val)
})
watch(index, (val) => {
reset()
emit('switch', val)
})
onMounted(() => {
registerEventListener()
// add tabindex then wrapper can be focusable via Javascript
// focus wrapper so arrow key can't cause inner scroll behavior underneath
wrapper.value?.focus?.()
})
return {
index,
wrapper,
imgRefs,
isSingle,
isFirst,
isLast,
currentImg,
imgStyle,
mode,
computedZIndex,
handleActions,
prev,
next,
hide,
toggleMode,
handleImgLoad,
handleImgError,
handleMouseDown,
ns,
}
},
onMounted(() => {
registerEventListener()
// add tabindex then wrapper can be focusable via Javascript
// focus wrapper so arrow key can't cause inner scroll behavior underneath
wrapper.value?.focus?.()
})
</script>

View File

@ -6,6 +6,12 @@ import {
mockImageEvent,
} from '@element-plus/test-utils/mock'
import Image from '../src/image.vue'
import type { AnchorHTMLAttributes, ImgHTMLAttributes } from 'vue'
import type { ImageProps } from '../src/image'
type ElImageProps = ImgHTMLAttributes &
AnchorHTMLAttributes &
Partial<ImageProps>
// firstly wait for image event
// secondly wait for vue render
@ -24,10 +30,13 @@ describe('Image.vue', () => {
test('image load success test', async () => {
const alt = 'this ia alt'
const wrapper = mount(Image, {
props: {
src: IMAGE_SUCCESS,
alt,
const wrapper = mount({
setup() {
const props: ElImageProps = {
alt,
src: IMAGE_SUCCESS,
}
return () => <Image {...props} />
},
})
expect(wrapper.find('.el-image__placeholder').exists()).toBe(true)
@ -70,11 +79,9 @@ describe('Image.vue', () => {
})
test('imageStyle fit test', async () => {
const fits = ['fill', 'contain', 'cover', 'none', 'scale-down']
const fits = ['fill', 'contain', 'cover', 'none', 'scale-down'] as const
for (const fit of fits) {
const wrapper = mount(Image, {
props: { fit, src: IMAGE_SUCCESS },
})
const wrapper = mount(() => <Image src={IMAGE_SUCCESS} fit={fit} />)
await doubleWait()
expect(wrapper.find('img').attributes('style')).toContain(
`object-fit: ${fit};`
@ -83,25 +90,23 @@ describe('Image.vue', () => {
})
test('preview classname test', async () => {
const wrapper = mount(Image, {
props: {
fit: 'cover',
src: IMAGE_SUCCESS,
previewSrcList: Array.from({ length: 3 }).fill(IMAGE_SUCCESS),
},
})
const props: ElImageProps = {
fit: 'cover',
src: IMAGE_SUCCESS,
previewSrcList: Array.from<string>({ length: 3 }).fill(IMAGE_SUCCESS),
}
const wrapper = mount(() => <Image {...props} />)
await doubleWait()
expect(wrapper.find('img').classes()).toContain('el-image__preview')
})
test('preview initial index test', async () => {
const wrapper = mount(Image, {
props: {
src: IMAGE_SUCCESS,
previewSrcList: Array.from({ length: 3 }).fill(IMAGE_FAIL),
initialIndex: 1,
},
})
const props: ElImageProps = {
src: IMAGE_SUCCESS,
previewSrcList: Array.from<string>({ length: 3 }).fill(IMAGE_FAIL),
initialIndex: 1,
}
const wrapper = mount(() => <Image {...props} />)
await doubleWait()
await wrapper.find('.el-image__inner').trigger('click')
expect(
@ -111,13 +116,12 @@ describe('Image.vue', () => {
test('$attrs', async () => {
const alt = 'this ia alt'
const wrapper = mount(Image, {
props: {
src: IMAGE_SUCCESS,
alt,
referrerpolicy: 'origin',
},
})
const props: ElImageProps = {
alt,
src: IMAGE_SUCCESS,
referrerpolicy: 'origin',
}
const wrapper = mount(() => <Image {...props} />)
await doubleWait()
expect(wrapper.find('img').attributes('alt')).toBe(alt)
expect(wrapper.find('img').attributes('referrerpolicy')).toBe('origin')
@ -125,12 +129,11 @@ describe('Image.vue', () => {
test('pass event listeners', async () => {
let result = false
const wrapper = mount(Image, {
props: {
src: IMAGE_SUCCESS,
onClick: () => (result = true),
},
})
const props: ElImageProps = {
src: IMAGE_SUCCESS,
onClick: () => (result = true),
}
const wrapper = mount(() => <Image {...props} />)
await doubleWait()
await wrapper.find('.el-image__inner').trigger('click')
expect(result).toBeTruthy()
@ -138,12 +141,11 @@ describe('Image.vue', () => {
test('emit load event', async () => {
const handleLoad = jest.fn()
const wrapper = mount(Image, {
props: {
src: IMAGE_SUCCESS,
onLoad: handleLoad,
},
})
const props: ElImageProps = {
src: IMAGE_SUCCESS,
onLoad: handleLoad,
}
const wrapper = mount(() => <Image {...props} />)
await doubleWait()
expect(wrapper.find('.el-image__inner').exists()).toBe(true)
expect(handleLoad).toBeCalled()

View File

@ -33,9 +33,8 @@
</div>
</template>
<script lang="ts">
import { computed, defineComponent, nextTick, onMounted, ref, watch } from 'vue'
import { isString } from '@vue/shared'
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
isBoolean,
isClient,
@ -53,244 +52,220 @@ import {
getScrollContainer,
isElement,
isInContainer,
isString,
} from '@element-plus/utils'
import { imageEmits, imageProps } from './image'
import type { CSSProperties, StyleValue } from 'vue'
defineOptions({
name: 'ElImage',
})
const props = defineProps(imageProps)
const emit = defineEmits(imageEmits)
let prevOverflow = ''
export default defineComponent({
name: 'ElImage',
components: {
ImageViewer,
useDeprecated(
{
scope: 'el-image',
from: 'append-to-body',
replacement: 'preview-teleported',
version: '2.2.0',
ref: 'https://element-plus.org/en-US/component/image.html#image-attributess',
},
inheritAttrs: false,
computed(() => isBoolean(props.appendToBody))
)
props: imageProps,
emits: imageEmits,
const { t } = useLocale()
const ns = useNamespace('image')
setup(props, { emit, attrs: rawAttrs }) {
useDeprecated(
{
scope: 'el-image',
from: 'append-to-body',
replacement: 'preview-teleported',
version: '2.2.0',
ref: 'https://element-plus.org/en-US/component/image.html#image-attributess',
},
computed(() => isBoolean(props.appendToBody))
const attrs = useAttrs()
const hasLoadError = ref(false)
const loading = ref(true)
const imgWidth = ref(0)
const imgHeight = ref(0)
const showViewer = ref(false)
const container = ref<HTMLElement>()
const _scrollContainer = ref<HTMLElement | Window>()
let stopScrollListener: () => void
let stopWheelListener: () => void
const containerStyle = computed(() => attrs.value.style as StyleValue)
const imageStyle = computed<CSSProperties>(() => {
const { fit } = props
if (isClient && fit) {
return { objectFit: fit }
}
return {}
})
const preview = computed(() => {
const { previewSrcList } = props
return Array.isArray(previewSrcList) && previewSrcList.length > 0
})
const teleported = computed(() => {
return props.appendToBody || props.previewTeleported
})
const imageIndex = computed(() => {
const { previewSrcList, initialIndex } = props
let previewIndex = initialIndex
if (initialIndex > previewSrcList.length - 1) {
previewIndex = 0
}
return previewIndex
})
const loadImage = () => {
if (!isClient) return
// reset status
loading.value = true
hasLoadError.value = false
const img = new Image()
const currentImageSrc = props.src
// load & error callbacks are only responsible for currentImageSrc
img.addEventListener('load', (e) => {
if (currentImageSrc !== props.src) {
return
}
handleLoad(e, img)
})
img.addEventListener('error', (e) => {
if (currentImageSrc !== props.src) {
return
}
handleError(e)
})
// bind html attrs
// so it can behave consistently
Object.entries(attrs.value).forEach(([key, value]) => {
// avoid onload to be overwritten
if (key.toLowerCase() === 'onload') return
img.setAttribute(key, value as string)
})
img.src = currentImageSrc
}
function handleLoad(e: Event, img: HTMLImageElement) {
imgWidth.value = img.width
imgHeight.value = img.height
loading.value = false
hasLoadError.value = false
}
function handleError(event: Event) {
loading.value = false
hasLoadError.value = true
emit('error', event)
}
function handleLazyLoad() {
if (isInContainer(container.value, _scrollContainer.value)) {
loadImage()
removeLazyLoadListener()
}
}
const lazyLoadHandler = useThrottleFn(handleLazyLoad, 200)
async function addLazyLoadListener() {
if (!isClient) return
await nextTick()
const { scrollContainer } = props
if (isElement(scrollContainer)) {
_scrollContainer.value = scrollContainer
} else if (isString(scrollContainer) && scrollContainer !== '') {
_scrollContainer.value =
document.querySelector<HTMLElement>(scrollContainer) ?? undefined
} else if (container.value) {
_scrollContainer.value = getScrollContainer(container.value)
}
if (_scrollContainer.value) {
stopScrollListener = useEventListener(
_scrollContainer,
'scroll',
lazyLoadHandler
)
setTimeout(() => handleLazyLoad(), 100)
}
}
const { t } = useLocale()
const ns = useNamespace('image')
function removeLazyLoadListener() {
if (!isClient || !_scrollContainer.value || !lazyLoadHandler) return
const attrs = useAttrs()
const hasLoadError = ref(false)
const loading = ref(true)
const imgWidth = ref(0)
const imgHeight = ref(0)
const showViewer = ref(false)
const container = ref<HTMLElement>()
stopScrollListener()
_scrollContainer.value = undefined
}
const _scrollContainer = ref<HTMLElement | Window>()
let stopScrollListener: () => void
let stopWheelListener: () => void
function wheelHandler(e: WheelEvent) {
if (!e.ctrlKey) return
const containerStyle = computed(() => rawAttrs.style as StyleValue)
if (e.deltaY < 0) {
e.preventDefault()
return false
} else if (e.deltaY > 0) {
e.preventDefault()
return false
}
}
const imageStyle = computed<CSSProperties>(() => {
const { fit } = props
if (isClient && fit) {
return { objectFit: fit }
}
return {}
})
function clickHandler() {
// don't show viewer when preview is false
if (!preview.value) return
const preview = computed(() => {
const { previewSrcList } = props
return Array.isArray(previewSrcList) && previewSrcList.length > 0
})
stopWheelListener = useEventListener('wheel', wheelHandler, {
passive: false,
})
const teleported = computed(() => {
return props.appendToBody || props.previewTeleported
})
// prevent body scroll
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
showViewer.value = true
}
const imageIndex = computed(() => {
const { previewSrcList, initialIndex } = props
let previewIndex = initialIndex
if (initialIndex > previewSrcList.length - 1) {
previewIndex = 0
}
return previewIndex
})
function closeViewer() {
stopWheelListener?.()
document.body.style.overflow = prevOverflow
showViewer.value = false
emit('close')
}
const loadImage = () => {
if (!isClient) return
function switchViewer(val: number) {
emit('switch', val)
}
watch(
() => props.src,
() => {
if (props.lazy) {
// reset status
loading.value = true
hasLoadError.value = false
const img = new Image()
const currentImageSrc = props.src
// load & error callbacks are only responsible for currentImageSrc
img.addEventListener('load', (e) => {
if (currentImageSrc !== props.src) {
return
}
handleLoad(e, img)
})
img.addEventListener('error', (e) => {
if (currentImageSrc !== props.src) {
return
}
handleError(e)
})
// bind html attrs
// so it can behave consistently
Object.entries(attrs.value).forEach(([key, value]) => {
// avoid onload to be overwritten
if (key.toLowerCase() === 'onload') return
img.setAttribute(key, value as string)
})
img.src = currentImageSrc
removeLazyLoadListener()
addLazyLoadListener()
} else {
loadImage()
}
}
)
function handleLoad(e: Event, img: HTMLImageElement) {
imgWidth.value = img.width
imgHeight.value = img.height
loading.value = false
hasLoadError.value = false
}
function handleError(event: Event) {
loading.value = false
hasLoadError.value = true
emit('error', event)
}
function handleLazyLoad() {
if (isInContainer(container.value, _scrollContainer.value)) {
loadImage()
removeLazyLoadListener()
}
}
const lazyLoadHandler = useThrottleFn(handleLazyLoad, 200)
async function addLazyLoadListener() {
if (!isClient) return
await nextTick()
const { scrollContainer } = props
if (isElement(scrollContainer)) {
_scrollContainer.value = scrollContainer
} else if (isString(scrollContainer) && scrollContainer !== '') {
_scrollContainer.value =
document.querySelector<HTMLElement>(scrollContainer) ?? undefined
} else if (container.value) {
_scrollContainer.value = getScrollContainer(container.value)
}
if (_scrollContainer.value) {
stopScrollListener = useEventListener(
_scrollContainer,
'scroll',
lazyLoadHandler
)
setTimeout(() => handleLazyLoad(), 100)
}
}
function removeLazyLoadListener() {
if (!isClient || !_scrollContainer.value || !lazyLoadHandler) return
stopScrollListener()
_scrollContainer.value = undefined
}
function wheelHandler(e: WheelEvent) {
if (!e.ctrlKey) return
if (e.deltaY < 0) {
e.preventDefault()
return false
} else if (e.deltaY > 0) {
e.preventDefault()
return false
}
}
function clickHandler() {
// don't show viewer when preview is false
if (!preview.value) return
stopWheelListener = useEventListener('wheel', wheelHandler, {
passive: false,
})
// prevent body scroll
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
showViewer.value = true
}
function closeViewer() {
stopWheelListener?.()
document.body.style.overflow = prevOverflow
showViewer.value = false
emit('close')
}
function switchViewer(val: number) {
emit('switch', val)
}
watch(
() => props.src,
() => {
if (props.lazy) {
// reset status
loading.value = true
hasLoadError.value = false
removeLazyLoadListener()
addLazyLoadListener()
} else {
loadImage()
}
}
)
onMounted(() => {
if (props.lazy) {
addLazyLoadListener()
} else {
loadImage()
}
})
return {
attrs,
loading,
hasLoadError,
showViewer,
containerStyle,
imageStyle,
preview,
imageIndex,
container,
ns,
teleported,
clickHandler,
closeViewer,
switchViewer,
t,
}
},
onMounted(() => {
if (props.lazy) {
addLazyLoadListener()
} else {
loadImage()
}
})
</script>