本图片预览组件主要包括以下功能:
展示图片时,可设置鼠标悬浮时的预览文本;图像无法加载时要显示的描述;自定义图像高度和宽度;设置图像如何适应容器高度和宽度( fill(填充) | contain(等比缩放包含) | cover(等比缩放覆盖));传入单张图像或图像数组;设置图像缩放比率;设置最大最小缩放比例; 单张图片预览时,左上角展示图片名称:可顺时针旋转或逆时针旋转;还原图片;放大缩小;鼠标任意拖动;鼠标双击图片还原;使用触摸板或鼠标滚轮控制图片缩放; 多张图片预览时,除了单张展示的功能以外:可点击左右切换按钮预览多张图片;使用键盘上下左右按键进行图片切换;设置是否可以循环切换图片;
可自定义设置以下属性:
图像地址 | 图像地址数组(src),类型:string | Array<{src: string, alt?: string}>,默认 '' 图像无法加载时显示的描述(alt),类型:string,默认 'image' 图像宽度(width),类型:string | number,单位px,默认 300 图像高度(height),类型:string | number,默认 '100%' 图形如何适应容器高度和宽度(fit),类型:'contain'|'fill'|'cover',默认'contain',可选 fill(填充)、contain(等比缩放包含)、cover(等比缩放覆盖) 预览文本(preview),类型:string | slot,默认 '预览' 每次缩放比率(zoomRatio),类型:number,默认 0.1 最小缩放比例(minZoomScale),类型:number,默认 0.1 最大缩放比例(maxZoomScale),类型:number,默认 10 缩放移动旋转图片后,是否可以双击还原(resetOnDbclick),类型:boolean,默认 true 是否可以循环切换图片(loop),类型:boolean,默认 false 相册模式,即从一张展示图片点开相册(album),类型:boolean,默认 false
效果如下图:在线预览
预览时样式:
正常展示时样式:
图片加载时样式:
鼠标悬浮时样式:
其中引入组件:Vue3加载(Spin)
①创建图片预览组件Image.vue:
import { computed, ref, onMounted, onUnmounted, watchEffect } from 'vue'
import Spin from '../spin'
interface Image {
src: string // 图像地址
name?: string // 图像名称
}
interface Props {
src: string|Image[] // 图像地址 | 图像地址数组
name?: string // 图像名称,没有传入图片名时自动从图像地址src中读取
width?: string|number // 图像宽度
height?: string|number // 图像高度
fit?: 'contain'|'fill'|'cover' // 图像如何适应容器高度和宽度
preview?: string // 预览文本 string | slot
zoomRatio?: number // 每次缩放比率
minZoomScale?: number // 最小缩放比例
maxZoomScale?: number // 最大缩放比例
resetOnDbclick?: boolean // 缩放移动旋转图片后,是否可以双击还原
loop?: boolean // 是否可以循环切换图片
album?: boolean // 是否相册模式,即从一张展示图片点开相册
}
const props = withDefaults(defineProps
src: '',
name: '',
width: 200,
height: 200,
fit: 'contain', // 可选 fill(填充) | contain(等比缩放包含) | cover(等比缩放覆盖)
preview: '预览',
zoomRatio: 0.1,
minZoomScale: 0.1,
maxZoomScale: 10,
resetOnDbclick: true,
loop: false,
album: false
})
const imageWidth = computed(() => {
if (typeof props.width === 'number') {
return props.width + 'px'
} else {
return props.width
}
})
const imageHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px'
} else {
return props.height
}
})
const images = ref
watchEffect(() => {
images.value = getImages()
})
function getImages () {
if (Array.isArray(props.src)) {
return props.src
} else {
return [{
src: props.src,
name: props.name
}]
}
}
const imageCount = computed(() => {
return images.value.length
})
onMounted(() => {
// 监听键盘切换事件
document.addEventListener('keydown', keyboardSwitch)
})
onUnmounted(() => {
// 移除键盘切换事件
document.removeEventListener('keydown', keyboardSwitch)
})
const complete = ref(Array(imageCount.value).fill(false)) // 图片是否加载完成
const loaded = ref(Array(imageCount.value).fill(false)) // 预览图片是否加载完成
const previewIndex = ref(0) // 当前预览的图片索引
const showPreview = ref(false) // 是否显示预览
const rotate = ref(0) // 预览图片旋转角度
const scale = ref(1) // 缩放比例
const sourceX = ref(0) // 拖动开始时位置
const sourceY = ref(0) // 拖动开始时位置
const dragX = ref(0) // 拖动横向距离
const dragY = ref(0) // 拖动纵向距离
function keyboardSwitch (e: KeyboardEvent) {
e.preventDefault()
if (showPreview.value && imageCount.value > 1) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
onSwitchLeft()
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
onSwitchRight()
}
}
}
function onComplete (n: number) { // 图片加载完成
complete.value[n] = true
}
function onLoaded (index: number) { // 预览图片加载完成
loaded.value[index] = true
}
function getImageName (image: Image) { // 从图像地址src中获取图像名称
if (image) {
if (image.name) {
return image.name
} else {
const res = image.src.split('?')[0].split('/')
return res[res.length - 1]
}
}
}
function onPreview (n: number) {
scale.value = 1
rotate.value = 0
dragX.value = 0
dragY.value = 0
showPreview.value = true
previewIndex.value = n
}
// 消除js加减精度问题的加法函数
function add (num1: number, num2: number) {
const num1DeciStr = String(num1).split('.')[1]
const num2DeciStr = String(num2).split('.')[1]
let maxLen = Math.max(num1DeciStr?.length || 0, num2DeciStr?.length || 0) // 两数中最长的小数位长度
let num1Str = num1.toFixed(maxLen) // 补零,返回字符串
let num2Str = num2.toFixed(maxLen)
const result = +(num1Str.replace('.', '')) + +(num2Str.replace('.', '')) // 转换为整数相加
return result / Math.pow(10, maxLen)
}
function onClose () {
showPreview.value = false
}
function onZoomin () { // 放大
if (scale.value + props.zoomRatio > props.maxZoomScale) {
scale.value = props.maxZoomScale
} else {
scale.value = add(scale.value, props.zoomRatio)
}
}
function onZoomout () { // 缩小
if (scale.value - props.zoomRatio < props.minZoomScale) {
scale.value = props.minZoomScale
} else {
scale.value = add(scale.value, -props.zoomRatio)
}
}
function onResetOrigin () { // 重置图片为初始状态
scale.value = 1
rotate.value = 0
dragX.value = 0
dragY.value = 0
}
function onWheel (e: WheelEvent) { // 鼠标滚轮缩放
// e.preventDefault() // 禁止浏览器捕获滑动事件
console.log('e', e)
const scrollZoom = e.deltaY * props.zoomRatio * 0.1 // 滚轮的纵向滚动量
if (scale.value === props.minZoomScale && scrollZoom > 0) {
return
}
if (scale.value === props.maxZoomScale && scrollZoom < 0) {
return
}
if (scale.value - scrollZoom < props.minZoomScale) {
scale.value = props.minZoomScale
} else if (scale.value - scrollZoom > props.maxZoomScale) {
scale.value = props.maxZoomScale
} else {
scale.value = add(scale.value, -scrollZoom)
}
}
function onAnticlockwiseRotate () { // 逆时针旋转
rotate.value -= 90
}
function onClockwiseRotate () { // 顺时针旋转
rotate.value += 90
}
function onMouseDown (event: MouseEvent) {
// event.preventDefault() // 消除拖动元素时的阴影
const el = event.target // 当前点击的元素
const imageRect = (el as Element).getBoundingClientRect()
const top = imageRect.top // 图片上边缘距浏览器窗口上边界的距离
const bottom = imageRect.bottom // 图片下边缘距浏览器窗口上边界的距离
const right = imageRect.right // 图片右边缘距浏览器窗口左边界的距离
const left = imageRect.left // 图片左边缘距浏览器窗口左边界的距离
const viewportWidth = document.body.clientWidth
const viewportHeight = document.body.clientHeight
sourceX.value = event.clientX // 鼠标按下时相对于视口左边缘的X坐标
sourceY.value = event.clientY // 鼠标按下时相对于视口上边缘的Y坐标
const sourceDragX = dragX.value // 鼠标按下时图片的X轴偏移量
const sourceDragY = dragY.value // 鼠标按下时图片的Y轴偏移量
document.onmousemove = (e: MouseEvent) => {
// e.clientX返回事件被触发时鼠标指针相对于浏览器可视窗口的水平坐标
dragX.value = sourceDragX + e.clientX - sourceX.value
dragY.value = sourceDragY + e.clientY - sourceY.value
}
document.onmouseup = () => {
if (dragX.value > sourceDragX + viewportWidth - right) { // 溢出视口右边缘
dragX.value = sourceDragX + viewportWidth - right
}
if (dragX.value < sourceDragX - left) { // 溢出视口左边缘
dragX.value = sourceDragX - left
}
if (dragY.value > sourceDragY + viewportHeight - bottom) { // 溢出视口下边缘
dragY.value = sourceDragY + viewportHeight - bottom
}
if (dragY.value < sourceDragY - top) { // 溢出视口上边缘
dragY.value = sourceDragY - top
}
document.onmousemove = null
}
}
function onSwitchLeft () {
if (props.loop) {
previewIndex.value = (previewIndex.value - 1 + imageCount.value) % imageCount.value
} else {
if (previewIndex.value > 0) {
previewIndex.value--
}
}
onResetOrigin()
}
function onSwitchRight () {
if (props.loop) {
previewIndex.value = (previewIndex.value + 1) % imageCount.value
} else {
if (previewIndex.value < imageCount.value - 1) {
previewIndex.value++
}
}
onResetOrigin()
}
class="m-image"
:class="{'image-hover-mask': complete[index]}"
:style="`width: ${imageWidth}; height: ${imageHeight};`"
v-for="(image, index) in images" :key="index"
v-show="!album || (album && index === 0)">
{{ getImageName(images[previewIndex]) }}
{{ (previewIndex + 1) }} / {{ imageCount }}
class="m-preview-image"
:style="`transform: translate3d(${dragX}px, ${dragY}px, 0px);`">
:spinning="!loaded[index]" indicator="dynamic-circle" v-show="previewIndex === index" v-for="(image, index) in images" :key="index"> class="u-preview-image" :style="`transform: scale3d(${scale}, ${scale}, 1) rotate(${rotate}deg);`" :src="image.src" :alt="image.name" @mousedown.prevent="onMouseDown($event)" @load="onLoaded(index)" @dblclick="resetOnDbclick ? onResetOrigin() : () => false"/>
class="m-switch-left"
:class="{'u-switch-disabled': previewIndex === 0 && !loop}"
@click="onSwitchLeft">
class="m-switch-right"
:class="{'u-switch-disabled': previewIndex === imageCount - 1 && !loop}"
@click="onSwitchRight">
.mask-enter-active, .mask-leave-active {
transition: opacity 0.3s ease-in-out;
}
.mask-enter-from, .mask-leave-to {
opacity: 0;
}
.preview-enter-active, .preview-leave-active {
transition: opacity 0.3s ease-in-out;
}
.preview-enter-from, .preview-leave-to {
opacity: 0;
}
.m-image-wrap {
display: inline-block;
.image-hover-mask {
&:hover {
.m-image-mask {
opacity: 1;
pointer-events: auto;
}
}
}
.m-image {
position: relative;
display: inline-block;
vertical-align: top;
margin-right: 12px;
margin-bottom: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
.u-image {
display: inline-block;
width: 100%;
height: 100%;
vertical-align: middle;
}
.m-image-mask {
// top right bottom left 简写为 inset: 0
// insert 无论元素的书写模式、行内方向和文本朝向如何,其所定义的都不是逻辑偏移而是实体偏移
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.5);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
.m-image-mask-info {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 4px;
.u-eye {
display: inline-flex;
align-items: center;
margin-right: 4px;
vertical-align: -0.125em;
width: 14px;
height: 14px;
fill: #FFF;
}
.u-pre {
display: inline-block;
}
}
}
}
.m-preview-mask {
position: fixed;
inset: 0;
z-index: 1000;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
}
.m-preview-wrap {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
outline: 0;
z-index: 1080;
height: 100%;
text-align: center;
.m-preview-body {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
.m-preview-operations {
position: fixed;
width: 100%;
z-index: 9;
display: flex;
flex-direction: row-reverse;
align-items: center;
background: rgba(0, 0, 0, 0.1);
height: 42px;
pointer-events: auto;
.u-name {
position: absolute;
left: 12px;
font-size: 14px;
color: rgb(255, 255, 255);
line-height: 1.57;
max-width: calc(50% - 60px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.u-preview-progress {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
color: rgb(255, 255, 255);
line-height: 1.57;
}
.u-preview-operation {
line-height: 1;
padding: 12px;
cursor: pointer;
transition: all 0.3s;
&:not(:last-child) {
margin-left: 12px;
}
&:hover {
background: rgba(0,0,0,0.25);
}
.u-icon {
width: 18px;
height: 18px;
vertical-align: bottom;
fill: #FFF;
}
}
.u-operation-disabled {
color: rgba(255, 255, 255, 0.25);
pointer-events: none;
.u-icon {
fill: rgba(255, 255, 255, 0.25);
}
}
}
.m-preview-image {
position: absolute;
z-index: 3;
inset: 0;
transition: transform 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) 0s;
display: flex;
justify-content: center;
align-items: center;
.u-preview-image {
display: inline-block;
vertical-align: middle;
max-width: 100vw;
max-height: 100vh;
cursor: grab;
transition: transform 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) 0s;
user-select: none;
pointer-events: auto;
}
}
.m-switch-left {
inset-inline-start: 12px;
position: fixed;
inset-block-start: 50%;
z-index: 1081;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-top: -20px;
color: rgb(255, 255, 255);
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
transform: translateY(-50%);
cursor: pointer;
transition: all 0.3s;
pointer-events: auto;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
.u-switch {
width: 18px;
height: 18px;
fill: #FFF;
}
}
.m-switch-right {
inset-inline-end: 12px;
position: fixed;
inset-block-start: 50%;
z-index: 1081;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-top: -20px;
color: rgb(255, 255, 255);
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
transform: translateY(-50%);
cursor: pointer;
transition: all 0.3s;
pointer-events: auto;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
.u-switch {
width: 18px;
height: 18px;
fill: #FFF;
}
}
.u-switch-disabled {
color: rgba(255, 255, 255, 0.25);
background: transparent;
cursor: not-allowed;
&:hover {
background: transparent;
}
.u-switch {
fill: rgba(255, 255, 255, 0.25);
}
}
}
}
}
②在要使用的页面引入:
import Image from './Image.vue'
import { ref } from 'vue'
const images = ref([
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/1.jpg',
name: 'image-1.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/2.jpg',
name: 'image-2.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/3.jpg',
name: 'image-3.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/4.jpg',
name: 'image-4.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/5.jpg',
name: 'image-5.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/6.jpg',
name: 'image-6.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/7.jpg',
name: 'image-7.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/8.jpg',
name: 'image-8.jpg'
},
{
src: 'https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/9.jpg',
name: 'image-9.jpg'
}
])
setTimeout(() => {
}, 1000)
Image 图片基本使用
多张图片预览,同时支持键盘 (left / right / up / down) 按键切换 (src: images)
多张图片预览,支持循环切换图片 (loop: true)
多张图片预览,相册模式 (album: true)
预览文本设为 preview 同时图片覆盖容器 (preview: preview & fit: cover)
preview
更改缩放比率和最大最小缩放比例 (zoomRatio: 0.2 & minZoomScale: 0.5 & maxZoomScale: 2)
:width="400" :height="300" :zoomRatio="0.2" :minZoomScale="0.5" :maxZoomScale="2" src="https://cdn.jsdelivr.net/gh/themusecatcher/resources@0.0.3/1.jpg" />
.u-pre {
display: inline-block;
font-size: 16px;
}
发表评论