This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import type { ExtractPropTypes, PropType } from 'vue'
import { CLOSE_EVENT } from '../_constants'
import type { Interceptor } from '../_utils'
import { commonProps, isNumber, makeArrayProp, makeNumberProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import type { ImageInterface } from './types'
export const imagepreviewProps = {
...commonProps,
/**
* @description 是否展示预览图片
*/
show: Boolean,
/**
* @description 是否缩放图片
*/
scale: Boolean,
/**
* @description 预览图片数组
*/
images: makeArrayProp<ImageInterface>([]),
/**
* @description 点击图片可以退出预览
*/
contentClose: truthProp,
/**
* @description 初始页码
*/
initNo: makeNumberProp(0),
/**
* @description 分页指示器是否展示
*/
paginationVisible: Boolean,
/**
* @description 分页指示器选中的颜色
*/
paginationColor: makeStringProp('#fff'),
/**
* @description 自动轮播时长0 表示不会自动轮播
*/
autoplay: makeNumericProp(3000),
/**
* @description 是否显示页码
*/
showIndex: truthProp,
/**
* 是否显示关闭图标
* @description 是否显示关闭图标
*/
closeable: Boolean,
/**
* @description 关闭图标位置,可选值:`top-left`
*/
closeIconPosition: makeStringProp<'top-right' | 'top-left'>('top-right'),
/**
* @description 关闭前的回调函数,返回 `false` 可阻止关闭,支持返回 `Promise`
*/
beforeClose: Function as PropType<Interceptor>,
/**
* @description 是否循环播放
*/
isLoop: truthProp,
}
export type ImagePreviewProps = ExtractPropTypes<typeof imagepreviewProps>
export const imagepreviewEmits = {
[CLOSE_EVENT]: () => true,
change: (val: number) => isNumber(val),
longPress: (image: ImageInterface) => image instanceof Object,
}
export type ImagePreviewEmits = typeof imagepreviewEmits

View File

@@ -0,0 +1,277 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, defineComponent, onMounted, reactive, watch } from 'vue'
import { CLOSE_EVENT, PREFIX } from '../_constants'
import { funInterceptor, getMainClass } from '../_utils'
import NutIcon from '../icon/icon.vue'
import NutPopup from '../popup/popup.vue'
import NutSwiper from '../swiper/swiper.vue'
import NutSwiperItem from '../swiperitem/swiperitem.vue'
import { imagepreviewEmits, imagepreviewProps } from './imagepreview'
import type { ImageInterface } from './types'
const props = defineProps(imagepreviewProps)
const emit = defineEmits(imagepreviewEmits)
const state = reactive({
showPop: false,
active: 0,
options: {
muted: true,
controls: true,
},
eleImg: null as HTMLElement | null,
store: {
scale: 1,
moveable: false,
originScale: 1,
oriDistance: 1,
},
lastTouchEndTime: 0, // 用来辅助监听双击
})
const styles = computed(() => {
const style: CSSProperties = {
}
if (props.closeIconPosition === 'top-right')
style.right = '10px'
else
style.left = '10px'
return style
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
// 设置当前选中第几个
function setActive(active: number) {
if (active !== state.active) {
state.active = active
emit('change', state.active)
}
}
function closeOnImg() {
if (props.contentClose)
onClose()
}
function onClose() {
funInterceptor(props.beforeClose, {
args: [state.active],
done: () => closeDone(),
})
}
// 执行关闭
function closeDone() {
state.showPop = false
state.store.scale = 1
scaleNow()
emit(CLOSE_EVENT)
}
// 计算两个点的距离
function getDistance(first: { x: number, y: number }, second: { x: number, y: number }) {
return Math.hypot(Math.abs(second.x - first.x), Math.abs(second.y - first.y))
}
function scaleNow() {
if (state.eleImg != null)
state.eleImg.style.transform = `scale(${state.store.scale})`
}
function onTouchStart(event: TouchEvent) {
const curTouchTime = new Date().getTime()
if (curTouchTime - state.lastTouchEndTime < 300) {
const store = state.store
if (store.scale > 1)
store.scale = 1
else if (store.scale === 1)
store.scale = 2
scaleNow()
}
const touches = event.touches
const events = touches[0]
const events2 = touches[1]
const store = state.store
store.moveable = true
if (events2) {
store.oriDistance = getDistance(
{
x: events.pageX,
y: events.pageY,
},
{
x: events2.pageX,
y: events2.pageY,
},
)
}
store.originScale = store.scale || 1
}
function onTouchMove(event: TouchEvent) {
if (!state.store.moveable)
return
const store = state.store
// event.preventDefault();
const touches = event.touches
const events = touches[0]
const events2 = touches[1]
// 双指移动
if (events2) {
// 获得当前两点间的距离
const curDistance = getDistance(
{
x: events.pageX,
y: events.pageY,
},
{
x: events2.pageX,
y: events2.pageY,
},
)
/**
* 此处计算倍数,距离放大(缩小) k 倍则 scale 也 扩大(缩小) k 倍。距离放大(缩小)倍数 = 结束时两点距离 除以 开始时两点距离
* 注意此处的 scale 变化是基于 store.scale 的。
* store.scale 是一个暂存值,比如第一次放大 2 倍,则 store.scale 为 2。
* 再次两指触碰的时候store.originScale 就为 store.scale 的值,基于此时的 store.scale 继续放大缩小。 *
*/
const curScale = curDistance / store.oriDistance
store.scale = store.originScale * curScale
// 最大放大 3 倍,缩小后松手要弹回原比例
if (store.scale > 3)
store.scale = 3
scaleNow()
}
}
function onTouchEnd() {
state.lastTouchEndTime = new Date().getTime()
const store = state.store
store.moveable = false
if ((store.scale < 1.1 && store.scale > 1) || store.scale < 1) {
store.scale = 1
scaleNow()
}
}
function longPress(image: ImageInterface) {
emit('longPress', image)
}
function init() {
document.addEventListener('touchmove', onTouchMove)
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
}
watch(
() => props.show,
(val) => {
state.showPop = val
if (val) {
setActive(props.initNo)
// #ifdef H5
init()
// #endif
}
},
)
watch(
() => props.initNo,
(val) => {
if (val !== state.active)
setActive(val)
},
)
onMounted(() => {
setActive(props.initNo)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-image-preview`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<NutPopup v-model:visible="state.showPop" :lock-scroll="true" pop-class="nut-image-preview-custom-pop">
<view :class="classes" :style="customStyle" @touchstart.capture="(onTouchStart as any)">
<NutSwiper
v-if="state.showPop"
:auto-play="autoplay"
custom-class="nut-image-preview-swiper"
:loop="isLoop"
:is-prevent-default="false"
direction="horizontal"
:init-page="initNo"
:pagination-visible="paginationVisible"
:pagination-color="paginationColor"
@change="setActive"
>
<NutSwiperItem v-for="(item, index) in images" :key="index">
<movable-area class="nut-image-movable-area">
<movable-view
:disabled="!scale"
inertia
scale-area
class="nut-image-preview-img"
:scale="scale"
direction="all"
>
<image
mode="aspectFit"
:src="item.src"
class="nut-image-preview-img"
@long-press="longPress(item)"
@long-tap="longPress(item)"
@click.stop="closeOnImg"
/>
</movable-view>
</movable-area>
</NutSwiperItem>
</NutSwiper>
</view>
<view v-if="showIndex" class="nut-image-preview-index">
{{ state.active + 1 }} / {{ images.length }}
</view>
<view
v-if="closeable"
class="nut-image-preview-close-icon"
:style="styles"
@click="onClose"
>
<NutIcon name="circle-close" custom-color="#ffffff" />
</view>
</NutPopup>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,93 @@
@import '../popup/index';
@import '../swiper/index';
@import '../swiperitem/index';
.nut-image-movable-area{
width: 100vw;
height: 100vh;
}
.nut-image-preview {
width: 100%;
height: 100%;
&-swiper {
width: 100vw;
height: 100%;
background-color: transparent;
}
&-img {
width: 100%;
height: 100%;
object-fit: contain;
}
&-index {
position: fixed;
top: 50px;
right: 0;
left: 0;
z-index: 2002;
color: #fff;
text-align: center;
background: transparent;
.arrow {
position: absolute;
left: 15px;
transform: rotateZ(180deg);
}
}
&-close-icon {
position: fixed;
top: 50px;
right: 15px;
z-index: 2002;
&-right {
right: 10px;
}
&-left {
left: 10px;
}
}
.popup-bg {
background: rgb(0 0 0 / 90%);
}
.popup-box {
height: 100%;
overflow: visible;
background-color: transparent;
}
}
.nut-image-preview-custom-pop {
display: flex;
align-items: center;
width: 100%;
height: 100%;
background: transparent !important;
}
.nut-image-preview-swiper .nut-swiper-item {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.nut-image-preview-box {
width: 100%;
}
.nut-video {
// height: auto;
video {
object-fit: contain;
}
}
}

View File

@@ -0,0 +1 @@
export type * from './imagepreview'

View File

@@ -0,0 +1,35 @@
import type { Interceptor } from '../_utils'
export interface ImageInterface {
src: string
}
export interface ImagePreviewOptions {
show: boolean
images: ImageInterface[]
videos: []
contentClose: boolean
initNo: number
paginationVisible: boolean
paginationColor: string
autoplay: [number, string]
isWrapTeleport: boolean
showIndex?: boolean
closeable?: boolean
closeIcon?: string
closeIconPosition?: string
beforeClose?: Interceptor
maxZoom?: number
minZoom?: number
isLoop?: boolean
close?: () => void
change?: (index: number) => void
}
export const baseProps = {
show: { type: Boolean, default: false },
initNo: { type: Number, default: 0 },
showIndex: { type: Boolean, default: true },
minZoom: { type: Number, default: 1 / 3 },
maxZoom: { type: Number, default: 3 },
}