组件目录结构如下:
options.ts文件用来存储配置文件, 代码如下:
import {isFirefox} from './tools';
export type ImageViewerAction = 'zoomIn' | 'zoomOut' | 'clocelise' | 'anticlocelise';
export const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel';
// 键盘按键值
export const EVENT_CODE = {
space: "Space",
left: "ArrowLeft", // 37
up: "ArrowUp", // 38
right: "ArrowRight", // 39
down: "ArrowDown", // 40
esc: "Escape",
};
// icon对应的svg
export const ICON_SVG = {
close: `<svg t="1628759850204" viewBox="0 0 1024 1024" width="24" height="24"><path d="M764 215.008L512 467.008 260 215.008q-10.016-8.992-22.496-8.992t-22.016 9.504-9.504 22.016 8.992 22.496l252 252-252 252q-12.992 12.992-8.512 31.008t22.016 22.496 31.488-8.512l252-252 252 252q10.016 8.992 22.496 8.992t22.016-9.504 9.504-22.016-8.992-22.496L556.992 512l252-252q12.992-12.992 8.512-31.008t-22.496-22.496-31.008 8.512z" p-id="3923" fill="#ffffff"></path></svg>`,
arrowLeft: '<svg t="1628762138411" viewBox="0 0 1024 1024" width="24" height="24"><path d="M608.992 148.992L277.984 489.984q-8.992 8.992-8.992 21.504t8.992 22.496l331.008 340.992q8.992 8 20.992 8t20.992-8.992 8.992-20.992-8.992-20.992l-312-320 312-320q8.992-8.992 8.992-20.992t-8.992-20.992-20.992-8.992-20.992 8z" p-id="5372" fill="#ffffff"></path></svg>',
arrowRight: '<svg t="1628762150860" viewBox="0 0 1024 1024" width="24" height="24"><path d="M340.992 148.992q-8.992 10.016-8.992 22.016t8.992 20.992l312 320-312 320q-8.992 8.992-8.992 20.992t8.992 20.992 20.992 8.992 20.992-8l331.008-340.992q8.992-8.992 8.992-22.016t-8.992-22.016L382.976 148.96q-8.992-8-20.992-8t-20.992 8z" p-id="5663" fill="#ffffff"></path></svg>',
zoomOut: '<svg t="1628761969296" viewBox="0 0 1024 1024" width="24" height="24"><path d="M796 751.008l124.992 124.992q8.992 10.016 8.992 22.496t-9.504 22.016-22.016 9.504-22.496-8.992l-124.992-124.992q-132.992 108.992-295.008 99.488t-280.992-132.512q-114.016-128.992-111.008-291.008t122.016-286.016q124-119.008 286.016-122.016t291.008 111.008q123.008 119.008 132.512 280.992t-99.488 295.008zM480 832q150.016-4 248.992-103.008T832 480q-4-150.016-103.008-248.992T480 128q-150.016 4-248.992 103.008T128 480q4 150.016 103.008 248.992T480 832z m-128-384h256q14.016 0 23.008 8.992T640 480t-8.992 23.008T608 512h-256q-14.016 0-23.008-8.992T320 480t8.992-23.008T352 448z" p-id="4889" fill="#ffffff"></path></svg>',
zoomIn: '<svg t="1628761724109" viewBox="0 0 1024 1024" width="24" height="24"><path d="M796 751.008l124.992 124.992q8.992 10.016 8.992 22.496t-9.504 22.016-22.016 9.504-22.496-8.992l-124.992-124.992q-132.992 108.992-295.008 99.488t-280.992-132.512q-114.016-128.992-111.008-291.008t122.016-286.016q124-119.008 286.016-122.016t291.008 111.008q123.008 119.008 132.512 280.992t-99.488 295.008zM480 832q150.016-4 248.992-103.008T832 480q-4-150.016-103.008-248.992T480 128q-150.016 4-248.992 103.008T128 480q4 150.016 103.008 248.992T480 832z m-32-384v-96q0-14.016 8.992-23.008T480 320t23.008 8.992T512 352v96h96q14.016 0 23.008 8.992T640 480t-8.992 23.008T608 512h-96v96q0 14.016-8.992 23.008T480 640t-23.008-8.992T448 608v-96h-96q-14.016 0-23.008-8.992T320 480t8.992-23.008T352 448h96z" p-id="4358" fill="#ffffff"></path></svg>',
original: '<svg t="1628762365130" viewBox="0 0 1024 1024" width="24" height="24"><path d="M812.992 180.992q26.016 0 43.008 16.992t16.992 43.008v482.016q0 24.992-17.504 42.496t-42.496 17.504H210.976q-24.992 0-42.496-17.504t-17.504-42.496V240.992q0-26.016 16.992-43.008t43.008-16.992h602.016z m0-60.992H210.976q-24 0.992-46.016 10.016t-39.008 26.016-26.496 39.008-9.504 46.016v482.016q0 24 9.504 46.016t26.496 39.008 39.008 26.016 46.016 8.992h602.016q24 0 46.016-8.992t39.008-26.016 26.496-39.008 9.504-46.016V241.056q0-24-9.504-46.016t-26.496-39.008-39.008-26.016-46.016-10.016z m-120 180.992q-12.992 0-21.504 8.992t-8.512 20.992v300.992q0 12.992 8.512 21.504t21.504 8.512 21.504-8.512 8.512-21.504v-300.992q0-12.992-8.512-21.504t-21.504-8.512z m-361.984 0q-12 0-20.992 8.992t-8.992 20.992v300.992q0 12.992 8.512 21.504t21.504 8.512 21.504-8.512 8.512-21.504v-300.992q0-12-8.512-20.992t-21.504-8.992zM512 360.992q-12.992 0.992-21.504 9.504t-8.512 21.504v30.016q0 12 8.512 20.512t21.504 8.512 21.504-8.512 8.512-20.512v-30.016q0-12.992-8.992-21.504t-20.992-9.504zM512 512q-12.992 0-21.504 8.512t-8.512 21.504v30.016q0 12.992 8.512 21.504t21.504 8.512 21.504-8.512 8.512-21.504v-30.016q0-12-8.992-20.992t-20.992-8.992z" p-id="5954" fill="#ffffff"></path></svg>',
fullScreen: '<svg t="1628762716543" viewBox="0 0 1024 1024" width="24" height="24"><path d="M160 96h192q14.016 0.992 23.008 10.016t8.992 22.496-8.992 22.496T352 160H160v192q0 14.016-8.992 23.008T128 384t-23.008-8.992T96 352V96h64z m0 832H96v-256q0-14.016 8.992-23.008T128 640t23.008 8.992T160 672v192h192q14.016 0 23.008 8.992t8.992 22.496-8.992 22.496T352 928H160zM864 96h64v256q0 14.016-8.992 23.008T896 384t-23.008-8.992T864 352V160h-192q-14.016 0-23.008-8.992T640 128.512t8.992-22.496T672 96h192z m0 832h-192q-14.016-0.992-23.008-10.016T640 895.488t8.992-22.496T672 864h192v-192q0-14.016 8.992-23.008T896 640t23.008 8.992T928 672v256h-64z" p-id="6683" fill="#ffffff"></path></svg>',
refreshLeft: '<svg t="1628762407981" viewBox="0 0 1024 1024" width="24" height="24"><path d="M288.992 296.992h92.992q14.016 0 23.008 8.992t8.992 22.496-8.992 22.496-23.008 10.016H232.992q-14.016-0.992-23.008-10.016t-8.992-22.016V179.968q0-14.016 8.992-23.008t23.008-8.992 23.008 8.992 8.992 23.008v50.016q86.016-76.992 196.512-95.488t217.504 26.496q106.016 48 167.488 142.016t62.496 210.016q-4 163.008-112.512 271.488t-271.488 112.512q-163.008-4-271.488-112.512t-112.512-271.488h64q3.008 136 93.504 226.496t226.496 93.504q136-3.008 226.016-93.504t94.016-226.496q-0.992-100.992-56-180.512t-148-117.504q-94.016-35.008-188.512-13.504T289.024 296.992z" p-id="6197" fill="#ffffff"></path></svg>',
refreshRight: '<svg t="1628762417349" viewBox="0 0 1024 1024" width="24" height="24"><path d="M784.992 230.016V180q0-14.016 8.992-23.008t22.496-8.992 22.496 8.992 10.016 23.008v148.992q-0.992 12.992-10.016 22.016t-22.016 10.016h-148.992q-14.016-0.992-23.008-10.016t-8.992-22.496 8.992-22.496 23.008-8.992h92.992q-78.016-82.016-183.488-99.488t-204.512 34.496q-98.016 54.016-140.992 152t-16.992 208q28.992 108 113.504 173.504t196.512 67.488q136-3.008 226.016-93.504t94.016-226.496h64q-4 163.008-112.512 271.488t-271.488 112.512q-163.008-4-271.488-112.512t-112.512-271.488q0.992-116 62.016-210.016t167.008-140.992q107.008-46.016 217.504-27.488t197.504 95.488z" p-id="6440" fill="#ffffff"></path></svg>',
};
tools.ts主要来书写相应的工具类,代码如下:
// 监听元素事件
export function on(
element: HTMLElement | Document | Window,
event: string,
handler: EventListenerOrEventListenerObject,
useCapture = false,
): void {
if (element && event && handler) {
element.addEventListener(event, handler, useCapture);
}
}
// 解绑元素事件
export function off(
element: HTMLElement | Document | Window,
event: string,
handler: EventListenerOrEventListenerObject,
useCapture = false,
): void {
if (element && event && handler) {
element.removeEventListener(event, handler, useCapture);
}
}
// 判断是否是火狐浏览器
export function isFirefox(): boolean {
return !!window.navigator.userAgent.match(/firefox/i);
}
// 定义一个参数为任何类型和数量且返回类型为 T 的函数声明
export type AnyFunction<T> = (...args: any[]) => T;
export function rafThrottle<T extends AnyFunction<any>>(fn: T): AnyFunction<void> {
let locked = false; // 定义一个锁变量来跟踪函数是否被锁定
return function (...args: any[]) { // 返回一个新的函数,该函数会被调用时执行 throttled 逻辑
if (locked) return; // 如果函数被锁定,直接返回,不执行
locked = true; // 锁定函数
window.requestAnimationFrame(() => { // 使用 requestAnimationFrame 来调度函数执行
// fn.apply(this, args); // 如果你希望函数在调用时保持上下文,取消注释这行
fn.apply(null, args); // 调用传入的函数 fn,this 参数设置为 null(可以根据需要调整)
locked = false; // 解锁函数,使其可以再次被调用
});
};
}
Preview.vue主要来写组件的页面布局和组件的逻辑处理,代码如下:
<template>
<teleport to="body" :disabled="!appendToBody">
<transition name="preview-fade">
<div ref="wrapper" :tabindex="-1" class="preview" :style="{ zIndex }" v-if="visible">
<!-- 蒙层 -->
<div class="preview__mask" @click.self="hideOnClickModal && hide()"></div>
<!-- 关闭按钮 -->
<span v-html="ICON_SVG.close" class="preview__btn preview__close" @click="hide" title="关闭"></span>
<!-- 左右箭头 -->
<template v-if="!isSingle">
<span
@click="prev"
:class="{ 'is-disabled': !infinite && isFirst }"
class="preview__btn preview__prev"
v-html="ICON_SVG.arrowLeft"
>
</span>
<span
@click="next"
:class="{ 'is-disabled': !infinite && isLast }"
class="preview__btn preview__next"
v-html="ICON_SVG.arrowRight"
>
</span>
</template>
<!-- 操作区 -->
<div class="preview__actions">
<span v-html="ICON_SVG.zoomOut" class="preview__icon" title="缩小" @click="handleActions('zoomOut')"></span>
<span v-html="ICON_SVG.zoomIn" class="preview__icon" title="放大" @click="handleActions('zoomIn')"></span>
<span
v-html="ICON_SVG.fullScreen"
class="preview__icon"
v-show="mode === 'original'"
title="原图"
@click="toggleMode('fullscreen')"
></span>
<span
v-html="ICON_SVG.original"
class="preview__icon"
v-show="mode === 'fullscreen'"
title="1:1"
@click="toggleMode('original')"
></span>
<span v-html="ICON_SVG.refreshLeft" class="preview__icon" title="左旋转" @click="handleActions('anticlocelise')"></span>
<span v-html="ICON_SVG.refreshRight" class="preview__icon" title="右旋转" @click="handleActions('clocelise')"></span>
</div>
<!-- 图片展示 -->
<div class="preview__canvas">
<img
v-for="(url, i) in imgPaths"
v-show="i === index"
ref="img"
class="preview__img"
:key="url"
:src="url"
:style="imgStyle"
@load="handleImgLoad"
@error="handleImgError"
@mousedown="handleMouseDown"
/>
<svg v-if="loading" viewBox="25 25 50 50" class="infinite-scroll__svg">
<circle cx="50" cy="50" r="20" class="infinite-scroll__circle"></circle>
</svg>
</div>
</div>
</transition>
</teleport>
</template>
<script lang="ts" setup>
import { onMounted, computed, PropType, ref, watch, nextTick } from "vue";
import { on, off, rafThrottle } from "./tools";
import { EVENT_CODE, ICON_SVG, ImageViewerAction, mousewheelEventName } from "./options";
let prevOverflow = "";
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
// 展示的图片路径列表
urlList: {
type: Array as PropType<string[]>,
default: () => [] as string[]
},
// 组件所处层级
zIndex: {
type: Number,
default: 2000
},
// 点击蒙层是否关闭
hideOnClickModal: {
type: Boolean,
default: false
},
// 预览的首张图片的位置
initialIndex: {
type: Number,
default: 0
},
// 是否无限循环预览
infinite: {
type: Boolean,
default: true
},
// 是否将组件插入至 body 元素上
appendToBody: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["close", "switch", "update:modelValue"]);
const visible = ref(false);
const wrapper = ref(null);
const img = ref(null);
const index = ref(props.initialIndex);
const loading = ref(true);
// 展示的图片数组
const imgPaths = ref(props.urlList);
// 按键按下的处理函数, 但要节流处理一下
let keyDownHandler: (() => void) | null;
// 处理鼠标滚动, 但要节流处理一下
let mouseWheelHandler: (() => void) | null;
// 拖动事件, 但要节流处理一下
let dragHandler: () => void;
// 是否存在箭头
const isSingle = computed(() => imgPaths.value.length <= 1);
// 是否是第一张
const isFirst = computed(() => index.value === 0);
// 是否是最后一张
const isLast = computed(() => index.value === imgPaths.value.length - 1);
// 图片模式, fullscreen: 当前屏幕的宽高比例 original: 原图
const mode = ref("original");
// 当前图片路径
const currentImg = computed(() => imgPaths.value[index.value]);
// 图片样式
const transform = ref({
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false
});
const imgStyle = 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`,
maxWidth: "none",
maxHeight: "none"
};
if (mode.value === "original") {
style.maxWidth = "100%";
style.maxHeight = "100%";
}
return style;
});
// 显示组件
const open = (imgUrls: string | string[]) => {
if (imgUrls) {
imgPaths.value = Array.isArray(imgUrls) ? imgUrls : [imgUrls];
}
show();
};
// 显示组件
const show = () => {
deviceSupportInstall();
prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
reset();
visible.value = true;
};
// 隐藏组件
const hide = () => {
deviceSupportUninstall();
document.body.style.overflow = prevOverflow;
visible.value = false;
emit("close");
emit("update:modelValue", false);
};
// 组件初始化, 绑定各种事件
const deviceSupportInstall = () => {
keyDownHandler = rafThrottle((e: KeyboardEvent) => {
switch (e.code) {
// ESC
case EVENT_CODE.esc:
hide();
break;
// SPACE
case EVENT_CODE.space:
toggleMode(mode.value === "original" ? "fullscreen" : "original");
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;
// no default
}
});
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);
};
// 组件销毁, 解绑各种事件
const deviceSupportUninstall = () => {
off(document, "keydown", keyDownHandler!);
off(document, mousewheelEventName, mouseWheelHandler!);
keyDownHandler = null;
mouseWheelHandler = null;
};
// 上一张
const prev = () => {
if (isFirst.value && !props.infinite) return;
const len = imgPaths.value.length;
index.value = (index.value - 1 + len) % len;
};
// 下一张
const next = () => {
if (isLast.value && !props.infinite) return;
const len = imgPaths.value.length;
index.value = (index.value + 1) % len;
};
// 设置图片的index值
const setImageIndex = (val: number) => {
if (val < 0 || val >= imgPaths.value.length) {
index.value = 0;
} else {
index.value = val;
}
};
// 鼠标在图片上按下事件
const handleMouseDown = (e: MouseEvent) => {
if (loading.value || e.button !== 0) return;
const { offsetX, offsetY } = transform.value;
const startX = e.pageX;
const startY = e.pageY;
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();
};
// 原图与1:1切换
const toggleMode = (type: string) => {
if (loading.value) return;
mode.value = type;
reset();
};
// 放大/缩小/左旋转/右旋转
const handleActions = (action: ImageViewerAction, 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;
// no default
}
transform.value.enableTransition = enableTransition;
};
// 图片加载完毕
const handleImgLoad = () => {
loading.value && (loading.value = false);
};
// 图片加载失败
const handleImgError = () => {
loading.value && (loading.value = false);
};
// 重置样式
const reset = () => {
transform.value = {
scale: 1,
deg: 0,
offsetX: 0,
offsetY: 0,
enableTransition: false
};
};
onMounted(() => {
if (props.modelValue) {
show();
}
// 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?.();
});
// 监听除第一张图片外, 每张图片是否加载完毕了
watch(currentImg, () => {
nextTick(() => {
const $img: HTMLImageElement = img.value!;
if (!$img.complete) {
loading.value = true;
}
});
});
// 监听每次切换
watch(index, val => {
reset();
emit("switch", val);
});
// 监听v-model
watch(
() => props.modelValue,
val => {
if (val) {
show();
} else if (visible.value) {
hide();
}
}
);
defineExpose({
hide,
open,
prev,
next,
setImageIndex
});
</script>
<style scoped>
.preview {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.preview__mask {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0.5;
background: #000;
}
.preview__btn {
width: 44px;
height: 44px;
position: absolute;
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
opacity: 0.8;
cursor: pointer;
box-sizing: border-box;
user-select: none;
background-color: rgb(96 98 102);
}
.preview__close {
top: 40px;
right: 40px;
}
.preview__prev {
top: 50%;
transform: translateY(-50%);
left: 40px;
}
.preview__next {
top: 50%;
transform: translateY(-50%);
right: 40px;
}
.preview__img {
cursor: move;
z-index: 1;
}
.is-disabled {
cursor: no-drop !important;
}
.preview__actions {
background-color: rgb(96 98 102);
position: absolute;
z-index: 10;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
cursor: pointer;
box-sizing: border-box;
user-select: none;
border-radius: 22px;
width: 282px;
height: 44px;
padding: 0 23px;
}
.preview__icon {
width: 24px;
height: 24px;
margin: 0 12px;
}
.preview__canvas {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
@keyframes preview-fade-in {
0% {
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
opacity: 0;
}
100% {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
@keyframes preview-fade-out {
0% {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
}
100% {
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
opacity: 0;
}
}
.infinite-scroll__svg {
transform-origin: center;
animation: rotate 2s linear infinite;
width: 50px;
position: absolute;
z-index: 0;
}
.infinite-scroll__circle {
fill: none;
stroke-width: 3;
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
stroke: #a5a5a5;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dashoffset: -125px;
}
}
</style>
使用时:
<template>
<div>
<ImagePreview ref="imagePreviewRef"></ImagePreview>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import ImagePreview from "@/components/ImagePreview/Preview.vue";
const imagePreviewRef = ref();
const showImage = (urlList: string[], index: number) => {
// 用来打开图片预览组件,传递一个图片组成的url数组,或者string类型的url
imagePreviewRef.value.open(urlList);
// 用来设置当前图片展示的下标
imagePreviewRef.value.setImageIndex(index);
};
</script>
<style lang="scss" scoped></style>