This commit is contained in:
gaofeihang committed 2025-09-18 00:04:51 +08:00
commit f1359db77a
24 files changed
+752 -19

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
-->
<template>
<img class="cursor-pointer" :src="getStaticImage(image)" @click="handleClick" />
<img class="cursor-pointer" draggable="false" :src="getStaticImage(image)" @click="handleClick" />
</template>
<script lang="ts" setup>
@@ -0,0 +1,326 @@
<template>
<div v-show="visible" class="sv-image-preview-root" :style="rootStyle" @click.self="onMaskClick">
<div class="sv-image-preview-toolbar">
<button class="sv-ip-btn" @click="emitClose">×</button>
<div class="sv-ip-page" v-if="images.length > 1">
{{ currentIndex + 1 }} / {{ images.length }}
</div>
</div>
<div
class="sv-image-stage"
ref="stageRef"
:class="{ panning: state.isPanning }"
@wheel.prevent="onWheel"
@mousedown.prevent="onMouseDown"
@mousemove.prevent="onMouseMove"
@mouseup.prevent="onMouseUp"
@mouseleave.prevent="onMouseUp"
@touchstart.passive="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend="onTouchEnd"
>
<transition name="fade">
<img
v-if="visible"
:key="currentIndex"
class="sv-image"
:class="{ 'no-transition': state.isPanning }"
:src="images[currentIndex]"
:style="imageStyle"
@dblclick="toggleZoom"
:draggable="false"
/>
</transition>
<button v-if="images.length > 1" class="sv-ip-nav prev" @click.stop="prev">‹</button>
<button v-if="images.length > 1" class="sv-ip-nav next" @click.stop="next">›</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
type Props = {
images: string[]
startIndex?: number
maskClosable?: boolean
zIndex?: number
// Scale metrics compatible with useScale.ts
designWidth?: number
designHeight?: number
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
}
const props = withDefaults(defineProps<Props>(), {
startIndex: 0,
maskClosable: true,
zIndex: 3000,
designWidth: 1920,
designHeight: 1080
})
const emit = defineEmits<{ (e: 'close'): void }>()
const visible = ref(true)
const currentIndex = ref(props.startIndex)
const stageRef = ref<HTMLElement | null>(null)
const state = reactive({
imgScale: 1,
translateX: 0,
translateY: 0,
isPanning: false,
startX: 0,
startY: 0,
pinchStartDistance: 0,
pinchStartScale: 1
})
// rAF batching for smoother pan
let rafId: number | null = null
let nextTranslateX = 0
let nextTranslateY = 0
function schedulePan(x: number, y: number) {
nextTranslateX = x
nextTranslateY = y
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
state.translateX = nextTranslateX
state.translateY = nextTranslateY
rafId = null
})
}
const images = computed(() => props.images)
const rootStyle = computed(() => {
const { scale, offsetX, offsetY } = props.getScaleMetrics()
return {
position: 'fixed',
left: `${offsetX}px`,
top: `${offsetY}px`,
width: `${props.designWidth}px`,
height: `${props.designHeight}px`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
zIndex: String(props.zIndex),
backgroundColor: 'rgba(0,0,0,0.8)'
} as Record<string, string>
})
const imageStyle = computed(() => ({
transform: `translate(${state.translateX}px, ${state.translateY}px) scale(${state.imgScale})`,
maxWidth: '80%',
maxHeight: '80%',
userSelect: 'none' as const
}))
function emitClose() {
visible.value = false
emit('close')
}
function onMaskClick() {
if (props.maskClosable) emitClose()
}
function onWheel(e: WheelEvent) {
const delta = -e.deltaY
const factor = delta > 0 ? 1.1 : 0.9
zoomAtCenter(factor)
}
function onMouseDown(e: MouseEvent) {
state.isPanning = true
state.startX = e.clientX - state.translateX
state.startY = e.clientY - state.translateY
}
function onMouseMove(e: MouseEvent) {
if (!state.isPanning) return
schedulePan(e.clientX - state.startX, e.clientY - state.startY)
}
function onMouseUp() {
state.isPanning = false
}
function distance(t1: Touch, t2: Touch) {
const dx = t1.clientX - t2.clientX
const dy = t1.clientY - t2.clientY
return Math.hypot(dx, dy)
}
function onTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
const t = e.touches[0]
state.isPanning = true
state.startX = t.clientX - state.translateX
state.startY = t.clientY - state.translateY
} else if (e.touches.length === 2) {
state.isPanning = false
state.pinchStartDistance = distance(e.touches[0], e.touches[1])
state.pinchStartScale = state.imgScale
}
}
let lastTap = 0
function onTouchEnd() {
const now = Date.now()
if (now - lastTap < 300) {
toggleZoom()
}
lastTap = now
state.isPanning = false
}
function onTouchMove(e: TouchEvent) {
if (e.touches.length === 1 && state.isPanning) {
const t = e.touches[0]
schedulePan(t.clientX - state.startX, t.clientY - state.startY)
} else if (e.touches.length === 2) {
const d = distance(e.touches[0], e.touches[1])
const factor = d / (state.pinchStartDistance || d)
state.imgScale = clamp(state.pinchStartScale * factor, 0.5, 5)
}
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function toggleZoom() {
if (state.imgScale === 1) {
state.imgScale = 2
} else {
resetTransform()
}
}
function zoomAtCenter(factor: number) {
const next = clamp(state.imgScale * factor, 0.5, 5)
state.imgScale = next
}
function resetTransform() {
state.imgScale = 1
state.translateX = 0
state.translateY = 0
}
function prev() {
if (images.value.length <= 1) return
currentIndex.value = (currentIndex.value - 1 + images.value.length) % images.value.length
resetTransform()
}
function next() {
if (images.value.length <= 1) return
currentIndex.value = (currentIndex.value + 1) % images.value.length
resetTransform()
}
// Close on Esc
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emitClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
watch(
() => props.images,
() => {
currentIndex.value = clamp(props.startIndex ?? 0, 0, Math.max(0, props.images.length - 1))
}
)
</script>
<style scoped>
.sv-image-preview-root {
/* Sized and positioned by rootStyle to align with useScale */
}
.sv-image-preview-toolbar {
position: absolute;
top: 16px;
right: 16px;
left: 16px;
display: flex;
justify-content: space-between;
align-items: center;
color: #fff;
z-index: 2;
}
.sv-ip-btn {
width: 36px;
height: 36px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 24px;
line-height: 34px;
text-align: center;
}
.sv-ip-page {
font-size: 14px;
opacity: 0.8;
}
.sv-image-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: grab;
z-index: 1;
}
.sv-image {
will-change: transform;
transition: transform 0.1s ease-out;
}
.sv-image.no-transition {
transition: none !important;
}
.sv-image-stage.panning {
cursor: grabbing;
}
.sv-ip-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
font-size: 24px;
line-height: 42px;
text-align: center;
}
.sv-ip-nav.prev {
left: 16px;
}
.sv-ip-nav.next {
right: 16px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+1
View File
@@ -0,0 +1 @@
export { default as ImagePreview } from './service.ts'
+32
View File
@@ -0,0 +1,32 @@
import { createVNode, render, reactive } from 'vue'
import ImagePreviewVue from './ImagePreview.vue'
type PreviewOptions = {
images: string[]
startIndex?: number
maskClosable?: boolean
zIndex?: number
// Supply scale metrics provider compatible with useScale
getScaleMetrics: () => { scale: number; offsetX: number; offsetY: number }
}
let container: HTMLElement | null = null
export default function previewImage(options: PreviewOptions) {
if (!container) {
container = document.createElement('div')
document.body.appendChild(container)
}
const state = reactive({ visible: true })
const vnode = createVNode(ImagePreviewVue, {
...options,
onClose: () => {
state.visible = false
render(null, container!)
}
})
render(vnode, container)
}
+3
View File
@@ -13,3 +13,6 @@ export * from './ProgressBar'
// 切换组件
export * from './Switcher'
// 图片预览(函数式)
export * from './ImagePreview'