背景
- 业务需求,有个列表,里面有图片、也有视频,那么点击其中一个需要打开预览展示
- 找了一圈发现Vue3 预览图片和视频,这个最符合要求
- 但是呢运行起来,没样式,并且存在bug,比如加载完视频后切到下一张图片,底部的操作栏就使用不了了
- 对此,进行了修改,补充了页面样式和图标,直接整个复制就可以使用了
<template>
<transition name="viewer-fade">
<div ref="wrapper" :tabindex="-1" class="el-image-viewer__wrapper" :style="{ zIndex }">
<div class="el-image-viewer__mask" @click.self="hideOnClickModal && hide()"></div>
<!-- CLOSE -->
<span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
<i class="el-icon close"
><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.752 512 214.528 764.224a31.936 31.936 0 1 0 45.12 45.184L512 557.184l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512.064l252.288-252.352a31.936 31.936 0 1 0-45.12-45.184z"
></path>
</svg>
</i>
</span>
<!-- ARROW -->
<template v-if="!isSingle">
<span
class="el-image-viewer__btn el-image-viewer__prev"
:class="{ 'is-disabled': !infinite && isFirst }"
@click="prev"
>
<i class="el-icon left-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.592 30.592 0 0 0 0-42.752L339.264 511.936l311.872-319.872a30.592 30.592 0 0 0 0-42.688 29.12 29.12 0 0 0-41.728 0z"
></path>
</svg>
</i>
</span>
<span
class="el-image-viewer__btn el-image-viewer__next"
:class="{ 'is-disabled': !infinite && isLast }"
@click="next"
>
<i class="el-icon right-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M340.864 149.312a30.592 30.592 0 0 0 0 42.752L652.736 512 340.864 831.872a30.592 30.592 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"
></path>
</svg>
</i>
</span>
</template>
<!-- ACTIONS -->
<div v-if="isImage" 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')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704M352 448h256a32 32 0 0 1 0 64H352a32 32 0 0 1 0-64"
></path>
</svg>
</i>
<i class="el-icon zoom-in" @click="handleActions('zoomIn')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704m-32-384v-96a32 32 0 0 1 64 0v96h96a32 32 0 0 1 0 64h-96v96a32 32 0 0 1-64 0v-96h-96a32 32 0 0 1 0-64z"
></path>
</svg>
</i>
<i class="el-image-viewer__actions__divider"></i>
<i class="el-icon" @click="toggleMode">
<template v-if="mode.icon.includes('full-screen')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="m160 96.064 192 .192a32 32 0 0 1 0 64l-192-.192V352a32 32 0 0 1-64 0V96h64zm0 831.872V928H96V672a32 32 0 1 1 64 0v191.936l192-.192a32 32 0 1 1 0 64zM864 96.064V96h64v256a32 32 0 1 1-64 0V160.064l-192 .192a32 32 0 1 1 0-64l192-.192zm0 831.872-192-.192a32 32 0 0 1 0-64l192 .192V672a32 32 0 1 1 64 0v256h-64z"
></path>
</svg>
</template>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M813.176 180.706a60.235 60.235 0 0 1 60.236 60.235v481.883a60.235 60.235 0 0 1-60.236 60.235H210.824a60.235 60.235 0 0 1-60.236-60.235V240.94a60.235 60.235 0 0 1 60.236-60.235h602.352zm0-60.235H210.824A120.47 120.47 0 0 0 90.353 240.94v481.883a120.47 120.47 0 0 0 120.47 120.47h602.353a120.47 120.47 0 0 0 120.471-120.47V240.94a120.47 120.47 0 0 0-120.47-120.47zm-120.47 180.705a30.118 30.118 0 0 0-30.118 30.118v301.177a30.118 30.118 0 0 0 60.236 0V331.294a30.118 30.118 0 0 0-30.118-30.118zm-361.412 0a30.118 30.118 0 0 0-30.118 30.118v301.177a30.118 30.118 0 1 0 60.236 0V331.294a30.118 30.118 0 0 0-30.118-30.118M512 361.412a30.118 30.118 0 0 0-30.118 30.117v30.118a30.118 30.118 0 0 0 60.236 0V391.53A30.118 30.118 0 0 0 512 361.412M512 512a30.118 30.118 0 0 0-30.118 30.118v30.117a30.118 30.118 0 0 0 60.236 0v-30.117A30.118 30.118 0 0 0 512 512"
></path>
</svg>
</template>
</i>
<i class="el-image-viewer__actions__divider"></i>
<i class="el-icon refresh-left" @click="handleActions('anticlocelise')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M289.088 296.704h92.992a32 32 0 0 1 0 64H232.96a32 32 0 0 1-32-32V179.712a32 32 0 0 1 64 0v50.56a384 384 0 0 1 643.84 282.88 384 384 0 0 1-383.936 384 384 384 0 0 1-384-384h64a320 320 0 1 0 640 0 320 320 0 0 0-555.712-216.448z"
></path>
</svg>
</i>
<i class="el-icon refresh-right" @click="handleActions('clocelise')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="currentColor"
d="M784.512 230.272v-50.56a32 32 0 1 1 64 0v149.056a32 32 0 0 1-32 32H667.52a32 32 0 1 1 0-64h92.992A320 320 0 1 0 524.8 833.152a320 320 0 0 0 320-320h64a384 384 0 0 1-384 384 384 384 0 0 1-384-384 384 384 0 0 1 643.712-282.88z"
></path>
</svg>
</i>
</div>
</div>
<!-- CANVAS -->
<div class="el-image-viewer__canvas">
<img
v-for="(item, i) in urlList"
v-show="i === index && isImage"
ref="media"
:key="item.url"
:src="i === index ? item.url : ''"
:style="mediaStyle"
class="el-image-viewer__img"
@load="handleMediaLoad"
@error="handleMediaError"
@mousedown="handleMouseDown"
/>
<video
controls="controls"
v-for="(item, i) in urlList"
v-show="i === index && isVideo"
ref="media"
:key="item.url"
:src="i === index ? item.url : ''"
:style="mediaStyle"
class="el-image-viewer__img"
@canplay="handleMediaLoad"
@error="handleMediaError"
@mousedown="handleMouseDown"
></video>
</div>
</div>
</transition>
</template>
<script>
import { computed, ref, onMounted, watch, nextTick } from "vue";
const EVENT_CODE = {
tab: "Tab",
enter: "Enter",
space: "Space",
left: "ArrowLeft", // 37
up: "ArrowUp", // 38
right: "ArrowRight", // 39
down: "ArrowDown", // 40
esc: "Escape",
delete: "Delete",
backspace: "Backspace"
};
const isFirefox = function () {
return !!window.navigator.userAgent.match(/firefox/i);
};
const rafThrottle = function (fn) {
let locked = false;
return function (...args) {
if (locked) return;
locked = true;
window.requestAnimationFrame(() => {
fn.apply(this, args);
locked = false;
});
};
};
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";
const CLOSE_EVENT = "close";
const SWITCH_EVENT = "switch";
export default {
name: "MediaViewer",
props: {
urlList: {
type: Array,
default: () => []
},
zIndex: {
type: Number,
default: 2000
},
initialIndex: {
type: Number,
default: 0
},
infinite: {
type: Boolean,
default: true
},
hideOnClickModal: {
type: Boolean,
default: false
}
},
emits: [CLOSE_EVENT, SWITCH_EVENT],
setup(props, { emit }) {
let _keyDownHandler = null;
let _mouseWheelHandler = null;
let _dragHandler = null;
const loading = ref(true);
const index = ref(props.initialIndex);
const wrapper = ref(null);
const media = ref(null);
const mode = ref(Mode.CONTAIN);
const 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 === props.urlList.length - 1;
});
const currentMedia = computed(() => {
return props.urlList[index.value];
});
const isVideo = computed(() => {
const currentUrl = props.urlList[index.value];
return currentUrl.type === "video";
});
const isImage = computed(() => {
const currentUrl = props.urlList[index.value];
return currentUrl.type === "image";
});
const mediaStyle = computed(() => {
const { scale, deg, offsetX, offsetY, enableTransition } = transform.value;
const style = {
transform: `scale(${scale}) rotate(${deg}deg)`,
transition: enableTransition ? "transform .3s" : "",
marginLeft: `${offsetX}px`,
marginTop: `${offsetY}px`
};
if (mode.value.name === Mode.CONTAIN.name) {
style.maxWidth = style.maxHeight = "100%";
}
return style;
});
function hide() {
deviceSupportUninstall();
emit(CLOSE_EVENT);
}
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
});
}
});
document.addEventListener("keydown", _keyDownHandler, false);
document.addEventListener(mousewheelEventName, _mouseWheelHandler, false);
}
function deviceSupportUninstall() {
document.removeEventListener("keydown", _keyDownHandler, false);
document.removeEventListener(mousewheelEventName, _mouseWheelHandler, false);
_keyDownHandler = null;
_mouseWheelHandler = null;
}
function handleMediaLoad() {
loading.value = false;
}
function handleMediaError(e) {
loading.value = false;
}
function handleMouseDown(e) {
if (loading.value || e.button !== 0) return;
const { offsetX, offsetY } = transform.value;
const startX = e.pageX;
const startY = e.pageY;
const divLeft = wrapper.value.clientLeft;
const divRight = wrapper.value.clientLeft + wrapper.value.clientWidth;
const divTop = wrapper.value.clientTop;
const divBottom = wrapper.value.clientTop + wrapper.value.clientHeight;
_dragHandler = rafThrottle((ev) => {
transform.value = {
...transform.value,
offsetX: offsetX + ev.pageX - startX,
offsetY: offsetY + ev.pageY - startY
};
});
document.addEventListener("mousemove", _dragHandler, false);
document.addEventListener(
"mouseup",
(e) => {
const mouseX = e.pageX;
const mouseY = e.pageY;
if (mouseX < divLeft || mouseX > divRight || mouseY < divTop || mouseY > divBottom) {
reset();
}
document.removeEventListener("mousemove", _dragHandler, false);
},
false
);
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 && !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, options = {}) {
if (loading.value) return;
const { zoomRate, rotateDeg, enableTransition } = {
zoomRate: 0.2,
rotateDeg: 90,
enableTransition: true,
...options
};
switch (action) {
case "zoomOut":
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(currentMedia, () => {
nextTick(() => {
const $media = media.value;
if (!$media.complete) {
loading.value = true;
}
});
});
watch(index, (val) => {
reset();
emit(SWITCH_EVENT, 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,
media,
isSingle,
isFirst,
isLast,
currentMedia,
isImage,
isVideo,
mediaStyle,
mode,
handleActions,
prev,
next,
hide,
toggleMode,
handleMediaLoad,
handleMediaError,
handleMouseDown
};
}
};
</script>
使用方式
<template>
<teleport to="body">
<MediaViewer
v-if="previewState.isShow"
:z-index="9999"
:initial-index="previewState.index"
:url-list="previewState.srcList"
:hide-on-click-modal="true"
@close="closeViewer"
/>
</teleport>
<div class="media-list">
<div
v-for="(item, index) in list"
:key="index"
class="media-item"
@click="openViewer(index)"
>
<img v-if="item.type === 'image'" :src="item.url" />
<video v-else :src="item.url" controls></video>
</div>
</div>
</template>
<script setup>
import MediaViewer from "./mediaViewer.vue"; // 需要修改为实际的路径
import { ref } from "vue";
// list中的item需要有个type和url,如果有其他需求,也可以直接修改上面的源码
const list = [
{
type: "image",
url: "https://picsum.photos/id/237/200/300",
},
{
type: "video",
url: "https://www.w3schools.com/html/mov_bbb.mp4",
},
{
type: "image",
url: "https://picsum.photos/id/238/200/300",
},
];
const previewState = ref({
isShow: false,
index: 0,
srcList: [],
});
function openViewer(index) {
previewState.value.isShow = true;
previewState.value.index = index;
previewState.value.srcList = list;
}
function closeViewer() {
previewState.value.isShow = false;
previewState.value.index = 0;
previewState.value.srcList = [];
}
</script>
参考链接
Vue3 预览图片和视频