238 lines
5.3 KiB
Vue
238 lines
5.3 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="preview">
|
|
<div v-if="visible" class="image-preview-overlay" @click.self="close">
|
|
<div class="image-preview-toolbar">
|
|
<button @click="zoomIn" title="放大">
|
|
<component :is="ZoomIn" :size="16" />
|
|
</button>
|
|
<button @click="zoomOut" title="缩小">
|
|
<component :is="ZoomOut" :size="16" />
|
|
</button>
|
|
<button @click="reset" title="重置">
|
|
<component :is="RotateCcw" :size="16" />
|
|
</button>
|
|
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
|
|
<button class="close-btn" @click="close" title="关闭">
|
|
<component :is="X" :size="16" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="image-preview-canvas"
|
|
ref="canvasRef"
|
|
@wheel.prevent="onWheel"
|
|
@mousedown="onMouseDown"
|
|
@mousemove="onMouseMove"
|
|
@mouseup="onMouseUp"
|
|
@mouseleave="onMouseUp"
|
|
>
|
|
<div
|
|
class="image-preview-content"
|
|
ref="contentRef"
|
|
:style="contentStyle"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
|
import { ZoomIn, ZoomOut, RotateCcw, X } from '@lucide/vue'
|
|
|
|
const props = defineProps({
|
|
visible: Boolean,
|
|
html: String,
|
|
})
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
const canvasRef = ref(null)
|
|
const contentRef = ref(null)
|
|
|
|
const scale = ref(1)
|
|
const translateX = ref(0)
|
|
const translateY = ref(0)
|
|
const isDragging = ref(false)
|
|
const lastX = ref(0)
|
|
const lastY = ref(0)
|
|
|
|
const contentStyle = computed(() => ({
|
|
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
|
|
transformOrigin: '0 0',
|
|
}))
|
|
|
|
watch(() => props.visible, (val) => {
|
|
if (val) {
|
|
scale.value = 1
|
|
translateX.value = 0
|
|
translateY.value = 0
|
|
nextTick(() => {
|
|
if (contentRef.value && props.html) {
|
|
contentRef.value.innerHTML = props.html
|
|
centerContent()
|
|
}
|
|
})
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
})
|
|
|
|
function centerContent() {
|
|
if (!canvasRef.value || !contentRef.value) return
|
|
const cw = canvasRef.value.clientWidth
|
|
const ch = canvasRef.value.clientHeight
|
|
const iw = contentRef.value.scrollWidth
|
|
const ih = contentRef.value.scrollHeight
|
|
const fitScale = Math.min(cw / iw, ch / ih, 1) * 0.85
|
|
scale.value = fitScale
|
|
translateX.value = (cw - iw * fitScale) / 2
|
|
translateY.value = (ch - ih * fitScale) / 2
|
|
}
|
|
|
|
function zoomIn() {
|
|
scale.value = Math.min(scale.value * 1.25, 5)
|
|
}
|
|
|
|
function zoomOut() {
|
|
scale.value = Math.max(scale.value * 0.8, 0.1)
|
|
}
|
|
|
|
function reset() {
|
|
centerContent()
|
|
}
|
|
|
|
function close() {
|
|
emit('close')
|
|
}
|
|
|
|
function onWheel(e) {
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
|
const newScale = Math.min(Math.max(scale.value * delta, 0.1), 5)
|
|
|
|
const rect = canvasRef.value.getBoundingClientRect()
|
|
const cx = e.clientX - rect.left
|
|
const cy = e.clientY - rect.top
|
|
|
|
translateX.value = cx - (cx - translateX.value) * (newScale / scale.value)
|
|
translateY.value = cy - (cy - translateY.value) * (newScale / scale.value)
|
|
scale.value = newScale
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (e.button !== 0) return
|
|
isDragging.value = true
|
|
lastX.value = e.clientX
|
|
lastY.value = e.clientY
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
if (!isDragging.value) return
|
|
translateX.value += e.clientX - lastX.value
|
|
translateY.value += e.clientY - lastY.value
|
|
lastX.value = e.clientX
|
|
lastY.value = e.clientY
|
|
}
|
|
|
|
function onMouseUp() {
|
|
isDragging.value = false
|
|
}
|
|
|
|
function onKeydown(e) {
|
|
if (e.key === 'Escape') close()
|
|
if (e.key === '+' || e.key === '=') zoomIn()
|
|
if (e.key === '-') zoomOut()
|
|
if (e.key === '0') reset()
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('keydown', onKeydown)
|
|
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.image-preview-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
display: flex;
|
|
flex-direction: column;
|
|
cursor: default;
|
|
}
|
|
|
|
.image-preview-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 8px 12px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(8px);
|
|
z-index: 1;
|
|
}
|
|
|
|
.image-preview-toolbar button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.image-preview-toolbar button:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.close-btn {
|
|
margin-left: auto;
|
|
}
|
|
|
|
.zoom-level {
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
min-width: 48px;
|
|
text-align: center;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.image-preview-canvas {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
cursor: grab;
|
|
position: relative;
|
|
}
|
|
|
|
.image-preview-canvas:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.image-preview-content {
|
|
position: absolute;
|
|
color: #fff;
|
|
}
|
|
|
|
.image-preview-content :deep(svg) {
|
|
max-width: none;
|
|
height: auto;
|
|
}
|
|
|
|
/* Transition */
|
|
.preview-enter-active,
|
|
.preview-leave-active {
|
|
transition: opacity 0.25s ease;
|
|
}
|
|
.preview-enter-from,
|
|
.preview-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|