Vue3.0实现图片预览组件(媒体查看器)
前言:最近项目中有个场景,一组图片、视频、音频、文件数据,要求点击图片可以放大预览,左右可以切换音视频、文件,支持鼠标及各种键控制 缩放,左右旋转,移动等功能,整理了一下,封了个组件,注释很全面,每块地方都有讲解,可以直接拿到项目中使用
先看下效果:
clg
关于传值:
(必传)传入url数组urlList,传入图片所处index,也就是在数组中的索引
(非必传)是否支持无限滚动?是否支持ESC键退出?是否支持点击遮罩层退出?是否需要工具栏?
关于图片的相关特效:
定义一个transform样式对象,包含缩放scale、旋转deg、移动offsetX|offsetY、动画enableTransition,可在computed计算中返回由此对象组成的css样式对象,在模板中对图片绑定,当去触发特效相关各类事件时,改变对象里某个值,则会重新捕获对象的改变执行computed,去实时更新图片样式
1. 缩放操作: 可以通过鼠标滚动上下滑动、键盘上下键up/down、组件内部工具栏按钮这三种方式去控制此特效,定义一个缩放比,即每次缩放的程度,可以根据项目场景自行定义,我这里键盘控制、按钮点击为1.4,鼠标滚轮偏小为1.2, 每次缩放让初始化的样式对象的scale每次乘或除以这个值即可,当然无限缩小无限放大肯定不行,需要定义一个最小最大值控制
2. 旋转操作: 定义一个旋转常量为90度,顺时针旋转让初始化样式对象deg累加这个值,逆时针相反即可
3. 移动操作: 顾名思义,也就是在遮罩层内可以通过鼠标对图片进行移动,在鼠标按下事件内,监听鼠标移动事件,每次移动记录下当前鼠标位置,计算offsetX也就是图片要移动的translateX为:移动前的距页面左侧距离offsetX加上当前的鼠标位置event.pageX – 移动前的鼠标位置; 上下移动同理
4. 动画过渡: 根据特效的触发方式决定是否需要过渡,我这里对通过鼠标操作缩放时没有定义动画,其余操作方式建议都要加上
关于图片初始化展示:
如果想要图片能够自适应在遮罩层的容器内,并且保证图片不变形且宽或高不溢出容器,那么就不应该定死宽高或者是不去定义宽高,我这里解决办法是对图片进行等比例的缩放,具体算法就不在这里过多讲解了,详情:https://blog.csdn.net/dabaooooq/article/details/128852363
关于音视频展示:
视频我这里用的是 vue3-video-play 这个插件,ui和功能各方面整体感觉很棒,算是对Vue 3.0支持比较好的一个吧,详情可以参考:https://codelife.cc/vue3-video-play/
音频用的是原生audio,用法很方便,没什么可讲的,具体看代码
关于抛出数据:
顶部中间一般为图片当前索引index/总长度,当然默认的为这样,抛出index,可以自定义这块地方插槽的使用,顶部左侧插槽也暴露当前index,其实当前组件最需要的数据也莫过于index,暴露方法中也基本都有抛出
附上完整代码
{{ index + 1 }} / {{ urlList.length }}
我们不能预览该文件。
您要先下载文件以查看。
下载
import 'element-plus/es/components/image-viewer/style/css'
import "vue3-video-play/dist/style.css";
import * as icons from './mediaIcons'
import { videoPlay } from "vue3-video-play/dist/index.es";
import { fileType, transformImgRatio } from "@/utils/dataUtils"; // fileType 判断文件后缀方法; transformImgRatio 等比例计算图片宽高方法
import { isNumber, useEventListener } from '@vueuse/core'
import { PropType } from 'vue';
import { useZIndex } from 'element-plus';
export default defineComponent({
name: 'imageViewerUtil',
props: {
urlList: { // url数组
type: Array as PropType,
default: () => []
},
imgIndex: { // 当前文件所处位置,也是在数组中的索引
type: Number,
default: 0
},
isTools: { // 是否需要工具栏
type: Boolean,
default: true
},
isInfinite: { // 是否支持无限循环滚动
type: Boolean,
default: true
},
zIndex: { // 层级
type: Number
},
closeOnPressEscape: { // 是否支持ESC键退出
type: Boolean,
default: true
},
isClickToDisappear: { // 是否支持通过点击遮罩层关闭
type: Boolean,
default: false
}
},
emits: ['close', 'download', 'prevIndex', 'nextIndex'],
setup(props, { emit }) {
const global = getCurrentInstance().appContext.config.globalProperties
const { nextZIndex } = useZIndex()
const modes = { // 模式对象
CONTAIN: {
name: 'contain',
icon: 'IconEpFullScreen',
},
ORIGINAL: {
name: 'original',
icon: 'IconEpScaleToOriginal',
},
}
const mode = shallowRef(modes.ORIGINAL) // 模式
const EVENT_CODE = { // 按钮对象
left: 'ArrowLeft', // 37
up: 'ArrowUp', // 38
right: 'ArrowRight', // 39
down: 'ArrowDown', // 40
esc: 'Escape',
space: 'Backspace'
}
const imageViewer = ref()
const data = reactive({
index: 0, // 图片索引,也是在数组中的位置
loading: true, // 处理加载
imgHeight: '', // 处理图片高
transform: {
scale: 1, // 缩放比
deg: 0, // 旋转角度
offsetX: 0,
offsetY: 0,
enableTransition: false // 是否需要过渡
}
})
// 是否是火狐
const isFirefox = (): boolean => /firefox/i.test(window.navigator.userAgent)
// 鼠标滚轮事件,火狐的不同
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel'
const computedZIndex = computed(() => { // 计算z-index值
return isNumber(props.zIndex) ? props.zIndex : nextZIndex()
})
const isSingle = computed(() => { // 是否是单张
return props.urlList.length { // 是否是第一张
return data.index === 0
})
const isLast = computed(() => { // 是否是最后一张
return data.index === props.urlList.length - 1
})
const isFileType = computed(() => { // 判断文件类型
return (url: string) => {
return fileType(url.split('?')[0])
}
})
const mediaStyle = computed(() => { // 图片css特效对象
const { scale, deg, offsetX, offsetY, enableTransition } = data.transform
let translateX = offsetX / scale
let translateY = offsetY / scale
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
}
return {
transform: `scale(${scale}) rotate(${deg}deg) translate(${translateX}px, ${translateY}px)`,
transition: enableTransition ? 'transform .3s' : ''
}
})
const handleImgLoad = (e) => { // 处理加载图片后的操作
// 计算图片等比例缩放后的宽高
const { clientWidth, clientHeight } = document.querySelector('#image-viewer-canvas') // 当前遮罩容器
const { width, height } = transformImgRatio(e.target.width, e.target.height, clientWidth, clientHeight - 40) // 计算等比例缩放后的图片宽高
data.imgHeight = height + 'px'
data.loading = false
}
const hide = () => { // 关闭
emit('close')
}
const prev = () => { // 上一张
if (!props.isInfinite && !data.index) return ElMessage({ type: 'info', message: '已经是第一张了!' })
data.index = (data.index - 1 + props.urlList.length) % props.urlList.length
resetStyle()
emit('prevIndex', data.index)
}
const next = () => { // 下一张
if (!props.isInfinite && data.index === props.urlList.length - 1) return ElMessage({ type: 'info', message: '已经是最后一张了!' })
data.index = (data.index + 1 + props.urlList.length) % props.urlList.length
resetStyle()
emit('nextIndex', data.index)
}
const keydownHandler = (e: event) => { // 键盘事件
switch (e.code) {
case EVENT_CODE.esc: // Escape
props.closeOnPressEscape && hide()
break;
case EVENT_CODE.left: // ArrowLeft
prev()
break;
case EVENT_CODE.right: // ArrowRight
next()
break;
case EVENT_CODE.up: // ArrowUp
handleActions('zoomIn')
break;
case EVENT_CODE.down: // ArrowDown
handleActions('zoomOut')
break;
case EVENT_CODE.space: // Backspace
toggleMode()
break
}
e.preventDefault()
}
const mousewheelHandler = (e: WheelEvent | any /* TODO: wheelDelta is deprecated */) => { // 鼠标滚轮事件
const delta = e.wheelDelta ? e.wheelDelta : -e.detail // 考虑Firefox
if (delta > 0) { // 向上
handleActions('zoomIn', { zooRate: 1.2, enableTransition: false })
} else { // 向下
handleActions('zoomOut', { zooRate: 1.2, enableTransition: false })
}
}
const handleActions = (action: any, option = {}) => { // 各类指令操作
const { zoomRate, rotateDeg, enableTransition } = { // 定义常规特效
zoomRate: 1.4,
rotateDeg: 90,
enableTransition: true,
...option,
}
switch (action) {
case 'zoomOut': // 缩小
if (data.transform.scale > 0.2) data.transform.scale = parseFloat((data.transform.scale / zoomRate).toFixed(3))
break;
case 'zoomIn': // 放大
if (data.transform.scale { // 左右切换重置transform对象
data.transform = { scale: 1, deg: 0, enableTransition: false }
}
const handleMediaLoad = (e: event) => { // 加载处理
data.loading = false
}
const handleMediaError = () => { // 图片 音频失败处理
data.loading = false
}
const handlePlayError = (index: number) => { // 视频失败处理
props.urlList[index] = props.urlList[index].split('?')[0] + '?v=' + new Date().getTime()
}
const download = (url: string) => { // 文件类型抛出url
emit('download', url)
}
const toggleMode = () => {
if (data.loading) return
const modeNames = Object.keys(modes)
const modeValues = Object.values(modes)
const currentMode = mode.value.name
const index = modeValues.findIndex((i) => i.name === currentMode)
const nextIndex = (index + 1) % modeNames.length
mode.value = modes[modeNames[nextIndex]]
resetStyle()
}
const handleMouseDown = (e: MouseEvent) => { // 处理鼠标按下事件
data.transform.enableTransition = false
const { offsetX, offsetY } = data.transform
const startX = e.pageX
const startY = e.pageY
// 拖拽事件
const dragHandler = (ev: MouseEvent) => {
data.transform = {
...data.transform,
offsetX: offsetX + ev.pageX - startX,
offsetY: offsetY + ev.pageY - startY,
}
}
// 添加鼠标移动事件监听
const removeMousemove = useEventListener(document, 'mousemove', dragHandler)
useEventListener(document, 'mouseup', () => {
removeMousemove()
})
e.preventDefault()
}
watch(() => props.imgIndex, () => {
data.index = props.imgIndex
}, { immediate: true })
onMounted(() => {
// 优化注册事件监听
useEventListener(document, 'keydown', keydownHandler)
useEventListener(document, mousewheelEventName, mousewheelHandler)
imageViewer.value?.focus?.()
})
return {
...toRefs(data),
computedZIndex,
mediaStyle,
isFileType,
isSingle,
isFirst,
isLast,
mode,
icons,
hide,
prev,
next,
handleImgLoad,
handleActions,
handleMediaLoad,
handlePlayError,
handleMediaError,
toggleMode,
download,
handleMouseDown
}
}
})
:deep(.el-image-viewer__canvas) {
//height: calc(100% - 180px);
align-items: center;
height: calc(100% - 40px);
}
:deep(.el-image-viewer__actions) {
height: 40px;
right: 50px;
top: 0;
left: auto;
z-index: inherit;
transform: none;
padding: 0;
background: none;
border-radius: 0;
}
:deep(.el-image-viewer__actions__divider) {
display: none;
}
:deep(.el-image-viewer__next) {
width: 64px;
height: 100px;
background: transparent;
border-radius: 6px 0px 0px 6px;
opacity: 0.8;
right: 0;
top: 45%;
.el-icon {
color: #999999;
font-size: 50px;
}
}
:deep(.el-image-viewer__prev) {
width: 64px;
height: 100px;
background: transparent;
border-radius: 0px 6px 6px 0px;
opacity: 0.8;
left: 0;
top: 45%;
.el-icon {
color: #999999;
font-size: 50px;
}
}
.el-image-viewer__next:hover,
.el-image-viewer__prev:hover {
background: #000000;
opacity: 1;
}
:deep(.el-image-viewer__mask) {
opacity: 0.8;
}
:deep(.el-image-viewer__close) {
height: 40px;
z-index: 56;
top: 0;
right: 10px;
background: transparent;
}
.image-viewer-carousel {
height: 140px;
background: #000000;
z-index: 50;
padding: 20px;
position: relative;
display: flex;
flex-wrap: wrap;
}
.image-viewer-header {
background-color: #000000;
box-sizing: border-box;
border-spacing: 0;
width: 100%;
height: 40px;
z-index: 55;
position: relative;
z-index: 55;
color: #FFFFFF;
text-align: center;
line-height: 40px;
.header-left {
position: absolute;
}
}
.header-file-icon {
width: 24px;
height: 50px;
display: inline-block;
margin-left: 20px;
background-image: url(../../../../assets/svg/image-icon.svg);
background-repeat: no-repeat;
background-position: 50% 50%;
/*这个是按从左往右,从上往下的百分比位置进行调整*/
background-size: 100% 60%;
/*按比例缩放*/
}
.header-file-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: top;
margin: 0 20px;
}
.image-viewer-tips {
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
box-sizing: border-box;
display: inline-block;
vertical-align: middle;
text-align: center;
min-width: 490px;
padding: 35px 100px;
line-height: 2em;
border-radius: 5px;
z-index: 56;
}
.image-unknown-file-type-view {
display: inline-block;
width: 96px;
height: 96px;
background-size: contain;
background: url(../../../../assets/svg/file-icon.svg);
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
}
.image-viewer-download {
box-sizing: border-box;
background: #f5f5f5;
border: 1px solid #ccc;
border-radius: 3.01px;
color: #333;
cursor: pointer;
display: inline-block;
font-family: inherit;
font-size: 14px;
font-variant: normal;
font-weight: 400;
height: 2.14285714em;
line-height: 1.42857143;
margin: 0;
padding: 4px 10px;
vertical-align: baseline;
white-space: nowrap;
text-decoration: none;
margin: 20px 10px 0 10px;
.icon-download {
position: relative;
top: 4px;
background: url(../../../../assets/svg/download.svg) no-repeat 0 0;
border: none;
margin: 0;
padding: 0;
text-indent: -999em;
vertical-align: text-bottom;
display: inline-block;
text-align: left;
line-height: 0;
position: relative;
vertical-align: text-top;
height: 16px;
width: 16px;
}
}
本文来自网络,不代表协通编程立场,如若转载,请注明出处:https://www.net2asp.com/7215513e47.html
