version:element-plus 1.0.1-beta.0
image
<template>
<div
ref="container"
:class="['el-image', $attrs.class]"
:style="$attrs.style"
>
<!-- 加载状态 -->
<slot v-if="loading" name="placeholder">
<div class="el-image__placeholder"></div>
</slot>
<!-- 加载出错 -->
<slot v-else-if="hasLoadError" name="error">
<div class="el-image__error">{{ t('el.image.error') }}</div>
</slot>
<!-- 加载成功 -->
<img
v-else
class="el-image__inner"
v-bind="attrs"
:src="src"
:style="imageStyle"
:class="{ 'el-image__inner--center': alignCenter, 'el-image__preview': preview }"
@click="clickHandler"
>
<template v-if="preview">
<image-viewer
v-if="showViewer"
:z-index="zIndex"
:initial-index="imageIndex"
:on-close="closeViewer"
:url-list="previewSrcList"
/>
</template>
</div>
</template>
<script lang='ts'>
import { defineComponent, computed, ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { isString } from '@vue/shared'
import throttle from 'lodash/throttle'
import { useAttrs } from '@element-plus/hooks'
import isServer from '@element-plus/utils/isServer'
import { on, off, getScrollContainer, isInContainer } from '@element-plus/utils/dom'
import { t } from '@element-plus/locale'
import ImageViewer from './image-viewer.vue'
// 是否支持 ObjectFit
const isSupportObjectFit = () => document.documentElement.style.objectFit !== undefined
// 是不是元素节点
const isHtmlEle = e => e && e.nodeType === 1
const ObjectFit = {
NONE: 'none',
CONTAIN: 'contain',
COVER: 'cover',
FILL: 'fill',
SCALE_DOWN: 'scale-down',
}
let prevOverflow = '' // 存放 body 的 overflow
export default defineComponent({
name: 'ElImage',
components: {
ImageViewer,
},
inheritAttrs: false,
props: {
src: {
type: String,
default: '',
},
fit: {
type: String,
default: '',
},
lazy: {
type: Boolean,
default: false,
},
scrollContainer: {
type: [String, Object],
default: null,
},
// 开启图片预览功能
previewSrcList: {
type: Array,
default: () => [],
},
zIndex: {
type: Number,
default: 2000,
},
},
emits: ['error'],
setup(props, { emit }) {
// init here
// 排除 class 和 style 和 on开头的事件 所有attrs
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 | null>(null)
let _scrollContainer = null
let _lazyLoadHandler = null
const imageStyle = computed(() => {
const { fit } = props
if (!isServer && fit) {
return isSupportObjectFit()
? { 'object-fit': fit }
: getImageStyle(fit)
}
return {}
})
const alignCenter = computed(() => {
const { fit } = props
return !isServer && !isSupportObjectFit() && fit !== ObjectFit.FILL
})
const preview = computed(() => {
const { previewSrcList } = props
return Array.isArray(previewSrcList) && previewSrcList.length > 0
})
const imageIndex = computed(() => {
const { src , previewSrcList } = props
let previewIndex = 0
const srcIndex = previewSrcList.indexOf(src)
if (srcIndex >= 0) {
previewIndex = srcIndex
}
return previewIndex
})
function getImageStyle(fit) {
const imageWidth = imgWidth.value
const imageHeight = imgHeight.value
if (!container.value) return {}
const {
clientWidth: containerWidth,
clientHeight: containerHeight,
} = container.value
if (!imageWidth || !imageHeight || !containerWidth || !containerHeight) return {}
// 是否是垂直的
// 宽 < 高
const vertical = imageWidth / imageHeight < 1
// https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-fit
// scale-down:内容的尺寸与 none 或 contain 中的一个相同,取决于它们两个之间谁得到的对象尺寸会更小一些。
if (fit === ObjectFit.SCALE_DOWN) {
// 实现
// 图片宽高都小于容器宽高就取 none 不然 contain
const isSmaller = imageWidth < containerWidth && imageHeight < containerHeight
fit = isSmaller ? ObjectFit.NONE : ObjectFit.CONTAIN
}
switch (fit) {
case ObjectFit.NONE:
return { width: 'auto', height: 'auto' }
case ObjectFit.CONTAIN:
return vertical ? { width: 'auto' } : { height: 'auto' }
case ObjectFit.COVER:
return vertical ? { height: 'auto' } : { width: 'auto' }
default:
return {}
}
}
const loadImage = () => {
if (isServer) return
const attributes = attrs.value
// reset status
loading.value = true
hasLoadError.value = false
const img = new Image()
img.onload = e => handleLoad(e, img)
img.onerror = handleError
// bind html attrs
// so it can behave consistently
Object.keys(attributes)
.forEach(key => {
const value = attributes[key]
img.setAttribute(key, value)
})
img.src = props.src
}
// image load 成功函数
function handleLoad(e: Event, img: HTMLImageElement) {
imgWidth.value = img.width
imgHeight.value = img.height
loading.value = false
hasLoadError.value = false
}
// image load 失败函数
function handleError(e: Event) {
loading.value = false
hasLoadError.value = true
emit('error', e)
}
// 懒加载处理函数
function handleLazyLoad() {
// 根据rect 判断在不在容器内
if (isInContainer(container.value, _scrollContainer)) {
loadImage()
removeLazyLoadListener()
}
}
function addLazyLoadListener() {
if (isServer) return
const { scrollContainer } = props
// 如果是元素节点 直接取
if (isHtmlEle(scrollContainer)) {
_scrollContainer = scrollContainer
}
else if (isString(scrollContainer) && scrollContainer !== '') {
// 是字符串且不为空字符串
_scrollContainer = document.querySelector(scrollContainer)
} else {
// 基本上就是找他的父节点 最高到window
_scrollContainer = getScrollContainer(container.value)
}
if (_scrollContainer) {
_lazyLoadHandler = throttle(handleLazyLoad, 200)
on(_scrollContainer, 'scroll', _lazyLoadHandler)
setTimeout(() => handleLazyLoad(), 100)
}
}
function removeLazyLoadListener() {
if (isServer || !_scrollContainer || !_lazyLoadHandler) return
off(_scrollContainer, 'scroll', _lazyLoadHandler)
_scrollContainer = null
_lazyLoadHandler = null
}
// 图片点击 预览
function clickHandler() {
// don't show viewer when preview is false
if (!preview.value) {
return
}
// prevent body scroll
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
// 渲染 viewer
showViewer.value = true
}
function closeViewer() {
// 恢复 body 的 overflow
document.body.style.overflow = prevOverflow
showViewer.value = false
}
// 接收到src变更 就开始加载图片
watch(() => props.src, () => {
loadImage()
})
onMounted(() => {
if (props.lazy) {
nextTick(addLazyLoadListener)
} else {
loadImage()
}
})
onBeforeUnmount(() => {
props.lazy && removeLazyLoadListener()
})
return {
attrs,
loading,
hasLoadError,
showViewer,
imgWidth,
imgHeight,
imageStyle,
alignCenter,
preview,
imageIndex,
clickHandler,
closeViewer,
container,
handleError,
t,
}
},
})
</script>
image-viewer
- 这个组件
element-ui
没有暴露出来,但是可以用来当预览图使用
<template>
<transition name="viewer-fade">
<div
ref="wrapper"
tabindex="-1"
class="el-image-viewer__wrapper"
:style="{ 'z-index': zIndex }"
>
<div class="el-image-viewer__mask"></div>
<!-- CLOSE -->
<span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
<i class="el-icon-circle-close"></i>
</span>
<!-- ARROW -->
<!-- 只有一张就不显箭头了 -->
<template v-if="!isSingle">
<!-- 左箭头 -->
<!-- infinite 写死返回的true 所有不会 disabled -->
<span
class="el-image-viewer__btn el-image-viewer__prev"
:class="{ 'is-disabled': !infinite && isFirst }"
@click="prev"
>
<i class="el-icon-arrow-left"></i>
</span>
<!-- 右箭头 -->
<span
class="el-image-viewer__btn el-image-viewer__next"
:class="{ 'is-disabled': !infinite && isLast }"
@click="next"
>
<i class="el-icon-arrow-right"></i>
</span>
</template>
<!-- ACTIONS -->
<!-- 下面的操作按钮 -->
<div class="el-image-viewer__btn el-image-viewer__actions">
<div class="el-image-viewer__actions__inner">
<i class="el-icon-zoom-out" @click="handleActions('zoomOut')"></i>
<i class="el-icon-zoom-in" @click="handleActions('zoomIn')"></i>
<i class="el-image-viewer__actions__divider"></i>
<i :class="mode.icon" @click="toggleMode"></i>
<i class="el-image-viewer__actions__divider"></i>
<i class="el-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
<i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
</div>
</div>
<!-- CANVAS -->
<div class="el-image-viewer__canvas">
<img
v-for="(url, i) in urlList"
v-show="i === index"
ref="img"
:key="url"
:src="currentImg"
:style="imgStyle"
class="el-image-viewer__img"
@load="handleImgLoad"
@error="handleImgError"
@mousedown="handleMouseDown"
>
</div>
</div>
</transition>
</template>
<script lang='ts'>
import { defineComponent, computed, ref, onMounted, watch, nextTick, PropType } from 'vue'
import { rafThrottle, isFirefox } from '@element-plus/utils/util'
import { on, off } from '@element-plus/utils/dom'
import { EVENT_CODE } from '@element-plus/utils/aria'
import { t } from '@element-plus/locale'
const Mode = {
CONTAIN: {
name: 'contain',
icon: 'el-icon-full-screen',
},
ORIGINAL: {
name: 'original',
icon: 'el-icon-c-scale-to-original',
},
}
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel'
export default defineComponent({
name: 'ElImageViewer',
props: {
urlList: {
type: Array as PropType<string[]>,
default: () => [],
},
zIndex: {
type: Number,
default: 2000,
},
onSwitch: {
type: Function,
default: () => ({}),
},
onClose: {
type: Function,
default: () => ({}),
},
initialIndex: {
type: Number,
default: 0,
},
},
setup(props) {
// init here
let _keyDownHandler = null
let _mouseWheelHandler = null
let _dragHandler = null
const loading = ref(true)
const index = ref(props.initialIndex)
const infinite = ref(true)
const wrapper = ref(null)
const img = ref(null)
const mode = ref(Mode.CONTAIN)
let transform = ref({
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
})
const isSingle = computed(() => {
const { urlList } = props
return urlList.length <= 1
})
const isFirst = computed(() => {
return index.value === 0
})
const isLast = computed(() => {
return index.value === 0
})
// 渠道当前 active image 的 src
const currentImg = computed(() => {
return props.urlList[index.value]
})
const imgStyle = computed(() => {
const { scale, deg, offsetX, offsetY, enableTransition } = transform.value
const style = {
transform: `scale(${scale}) rotate(${deg}deg)`,
transition: enableTransition ? 'transform .3s' : '',
'margin-left': `${offsetX}px`,
'margin-top': `${offsetY}px`,
}
if (mode.value.name === Mode.CONTAIN.name) {
style.maxWidth = style.maxHeight = '100%'
}
return style
})
function hide() {
deviceSupportUninstall()
props.onClose()
}
function deviceSupportInstall() {
// 键盘事件
_keyDownHandler = rafThrottle(e => {
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
}
})
// 鼠标事件
_mouseWheelHandler = rafThrottle(e => {
const delta = e.wheelDelta ? e.wheelDelta : -e.detail
if (delta > 0) {
handleActions('zoomIn', {
zoomRate: 0.015,
enableTransition: false,
})
} else {
handleActions('zoomOut', {
zoomRate: 0.015,
enableTransition: false,
})
}
})
on(document, 'keydown', _keyDownHandler)
on(document, mousewheelEventName, _mouseWheelHandler)
}
function deviceSupportUninstall() {
off(document, 'keydown', _keyDownHandler)
off(document, mousewheelEventName, _mouseWheelHandler)
_keyDownHandler = null
_mouseWheelHandler = null
}
function handleImgLoad() {
loading.value = false
}
function handleImgError(e) {
loading.value = false
e.target.alt = t('el.image.error')
}
function handleMouseDown(e) {
if (loading.value || e.button !== 0) return
const { offsetX, offsetY } = transform.value
const startX = e.pageX
const startY = e.pageY
// requestAnimationFrame throttle
_dragHandler = rafThrottle(ev => {
transform.value = {
...transform.value,
offsetX: offsetX + ev.pageX - startX,
offsetY: offsetY + ev.pageY - startY,
}
})
on(document, 'mousemove', _dragHandler)
on(document, 'mouseup', () => {
off(document, 'mousemove', _dragHandler)
})
e.preventDefault()
}
function reset() {
transform.value = {
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false,
}
}
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 && !infinite.value) return
const len = props.urlList.length
// + len 防止 < len
index.value = (index.value - 1 + len) % len
}
function next() {
if (isLast.value && !infinite.value) return
const len = props.urlList.length
index.value = (index.value + 1) % len
}
function handleActions(action, options = {}) {
if (loading.value) return
const { zoomRate, rotateDeg, enableTransition } = {
zoomRate: 0.2,
rotateDeg: 90,
enableTransition: true,
...options,
}
switch (action) {
case 'zoomOut':
// 最小缩放 0.2
if (transform.value.scale > 0.2) {
// 保留三位 浏览器 四舍五入 保留两位
transform.value.scale = parseFloat((transform.value.scale - zoomRate).toFixed(3))
}
break
case 'zoomIn':
// 最大缩放没有限制
transform.value.scale = parseFloat((transform.value.scale + zoomRate).toFixed(3))
break
case 'clocelise':
transform.value.deg += rotateDeg
break
case 'anticlocelise':
transform.value.deg -= rotateDeg
break
}
transform.value.enableTransition = enableTransition
}
// 监听当前图片
watch(currentImg, () => {
nextTick(() => {
const $img = img.value
if (!$img.complete) {
loading.value = true
}
})
})
// index 更新 reset
watch(index, val => {
reset()
props.onSwitch(val)
})
onMounted(() => {
deviceSupportInstall()
// 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,
img,
infinite: true, // ? 为啥写死
loading: false, // template 没有用到
isSingle,
isFirst,
isLast,
currentImg,
imgStyle,
mode,
handleActions,
prev,
next,
hide,
toggleMode,
handleImgLoad,
handleImgError,
handleMouseDown,
}
},
})
</script>