forked from zhongyidian/super_video
Merge branch 'main' of https://gitee.com/zzyyymm/super_video
This commit is contained in:
commit
f1359db77a
24 files changed
+752
-19
No files matched your search
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ImagePreview } from './service.ts'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export * from './ProgressBar'
|
||||
|
||||
// 切换组件
|
||||
export * from './Switcher'
|
||||
|
||||
// 图片预览(函数式)
|
||||
export * from './ImagePreview'
|
||||
Reference in new issue
Block a user