当前位置:首页 » 《随便一记》 » 正文

Vue3.0实现图片预览组件(媒体查看器)

24 人参与  2023年05月05日 14:25  分类 : 《随便一记》  评论

点击全文阅读


前言:最近项目中有个场景,一组图片、视频、音频、文件数据,要求点击图片可以放大预览,左右可以切换音视频、文件,支持鼠标及各种键控制 缩放,左右旋转,移动等功能,整理了一下,封了个组件,注释很全面,每块地方都有讲解,可以直接拿到项目中使用

先看下效果:

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,暴露方法中也基本都有抛出

附上完整代码

<template>    <teleport to='body'>        <transition>            <div class="el-image-viewer__wrapper" :tabindex="-1" ref="imageViewer" :style="{ zIndex: computedZIndex }">                <!-- MASK -->                <div class="el-image-viewer__mask" @click.self="isClickToDisappear && hide"></div>                <!-- CLOSE -->                <span class="el-image-viewer__btn el-image-viewer__close" @click="hide">                    <el-icon>                        <Close />                    </el-icon>                </span>                <!-- HEADER -->                <div class="image-viewer-header" v-if="isTools">                    <div class="header-left">                        <span class="header-file-name">                            <slot name="title" :index="index"></slot>                        </span>                        <slot name="left" :index="index"></slot>                    </div>                    <div class="header-center">                        <slot name="content" :index="index">                            {{ index + 1 }} / {{ urlList.length }}                        </slot>                    </div>                </div>                <!-- ACTIONS -->                <div class="el-image-viewer__btn el-image-viewer__actions"                    v-if="isFileType(urlList[index]) === 'img' && isTools">                    <div class="el-image-viewer__actions__inner">                        <el-icon @click="handleActions('zoomOut')">                            <ZoomOut />                        </el-icon>                        <el-icon @click="handleActions('zoomIn')">                            <ZoomIn />                        </el-icon>                        <el-icon @click="toggleMode">                            <component :is="icons.default[mode.icon]"></component>                        </el-icon>                        <el-icon @click="handleActions('anticlockwise')">                            <RefreshLeft />                        </el-icon>                        <el-icon @click="handleActions('clockwise')">                            <RefreshRight />                        </el-icon>                    </div>                </div>                <!-- ARROW -->                <template v-if="!isSingle">                    <span class="el-image-viewer__btn el-image-viewer__prev" @click="prev">                        <el-icon>                            <ArrowLeftBold />                        </el-icon>                    </span>                    <span class="el-image-viewer__btn el-image-viewer__next" @click="next">                        <el-icon>                            <ArrowRightBold />                        </el-icon>                    </span>                </template>                <!-- CANVAS -->                <div id="image-viewer-canvas" class="el-image-viewer__canvas">                    <div v-for="(url, i) in urlList" :key="url" style="display: flex" :data-id="url">                        <!-- 图片显示 -->                        <img v-if="i === index && isFileType(url) === 'img'" v-loading="loading" :src="url"                            :style="[mediaStyle, { width: 'auto', height: imgHeight }]" class="el-image-viewer__img"                            @load="handleImgLoad" @error="handleMediaError" @mousedown="handleMouseDown" />                        <!-- 视频显示 -->                        <videoPlay v-if="(i === index && isFileType(url) === 'video')" :src="url"                            class="el-image-viewer__img" @load="handleMediaLoad" @error="handlePlayError(i)"                            @mousedown="handleMouseDown">                        </videoPlay>                        <!-- 音频显示 -->                        <audio controls="controls" v-if="i === index && isFileType(url) === 'audio'" :src="url"                            class="el-image-viewer__img" @load="handleMediaLoad" @error="handleMediaError"></audio>                        <!-- 文本文件显示 -->                        <div v-if="i === index && ['file', 'text'].includes(isFileType(url))" class="image-viewer-tips">                            <span class="image-unknown-file-type-view"></span>                            <p>                                我们不能预览该文件。<br>                                您要先下载文件以查看。                            </p>                            <div class="image-viewer-download" @click="download(url)">                                <span class="icon-download"></span>                                下载                            </div>                        </div>                    </div>                </div>            </div>        </transition>    </teleport></template><script lang="ts">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<string[]>,            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<HTMLDivElement>()        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 <= 1        })        const isFirst = computed(() => { // 是否是第一张            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 < 6) data.transform.scale = parseFloat((data.transform.scale * zoomRate).toFixed(3))                    break;                case 'anticlockwise': // 逆时针旋转                    data.transform.deg -= rotateDeg                    break;                case 'clockwise': // 顺时针旋转                    data.transform.deg += rotateDeg                    break;            }            data.transform.enableTransition = enableTransition        }        const resetStyle = () => { // 左右切换重置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        }    }})</script><style lang="scss" scoped>: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;    }}</style>


点击全文阅读


本文链接:http://zhangshiyu.com/post/60917.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1