mirror of
https://github.com/sendevia/website.git
synced 2026-03-07 00:02:33 +08:00
592 lines
15 KiB
Vue
592 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||
|
||
interface Props {
|
||
images: string[];
|
||
currentIndex: number;
|
||
originPosition?: { x: number; y: number; width: number; height: number };
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
originPosition: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
close: [];
|
||
"update:currentIndex": [index: number];
|
||
}>();
|
||
|
||
// 缩放配置常量
|
||
const ZOOM_MIN = 0.9; // 最小缩放
|
||
const ZOOM_MAX = 1.6; // 最大缩放
|
||
const ZOOM_STEP = 0.15; // 缩放步长
|
||
const TOUCH_MOVE_THRESHOLD = 10; // 触摸移动阈值 (px)
|
||
|
||
// 状态
|
||
const isVisible = ref(false);
|
||
const isAnimating = ref(false);
|
||
const imageTransition = ref(false);
|
||
const windowSize = ref({ width: 0, height: 0 });
|
||
const initialTransform = ref({ scale: 0, translateX: 0, translateY: 0 });
|
||
const imageScale = ref(1);
|
||
const minScale = ref(ZOOM_MIN);
|
||
const maxScale = ref(ZOOM_MAX);
|
||
const isZooming = ref(false);
|
||
const isDragging = ref(false);
|
||
const imagePosition = ref({ x: 0, y: 0 });
|
||
const previousActiveElement = ref<HTMLElement | null>(null);
|
||
|
||
// 计算属性
|
||
const currentImage = computed(() => props.images[props.currentIndex]);
|
||
const hasPrevious = computed(() => props.currentIndex > 0);
|
||
const hasNext = computed(() => props.currentIndex < props.images.length - 1);
|
||
|
||
function calculateInitialTransform() {
|
||
if (props.originPosition && props.originPosition.width > 0 && props.originPosition.height > 0) {
|
||
const viewportCenterX = window.innerWidth / 2;
|
||
const viewportCenterY = window.innerHeight / 2;
|
||
const translateX = props.originPosition.x - viewportCenterX;
|
||
const translateY = props.originPosition.y - viewportCenterY;
|
||
const targetWidth = Math.min(window.innerWidth * 0.85, window.innerHeight * 0.75);
|
||
const scale = targetWidth > 0 ? props.originPosition.width / targetWidth : 0.01;
|
||
|
||
initialTransform.value = {
|
||
scale,
|
||
translateX,
|
||
translateY,
|
||
};
|
||
}
|
||
}
|
||
|
||
function show() {
|
||
// 保存之前焦点
|
||
previousActiveElement.value = document.activeElement as HTMLElement | null;
|
||
|
||
isVisible.value = true;
|
||
|
||
// 每次打开都重置缩放和位置,确保从文章原位置展开
|
||
resetZoom();
|
||
calculateInitialTransform();
|
||
|
||
// 开始入场
|
||
setTimeout(() => {
|
||
isAnimating.value = true;
|
||
}, 10);
|
||
|
||
// 在动画完成后,将焦点设置到关闭按钮
|
||
// setTimeout(() => {
|
||
// const btn = document.querySelector<HTMLButtonElement>(".image-viewer__close");
|
||
// if (btn) {
|
||
// btn.focus();
|
||
// }
|
||
// }, 300);
|
||
}
|
||
|
||
function hide() {
|
||
// 重新计算 initialTransform(页面可能已滚动),以便回到正确位置
|
||
calculateInitialTransform();
|
||
|
||
// 在下一帧触发状态改变,确保浏览器能够检测到过渡的起点
|
||
requestAnimationFrame(() => {
|
||
// 开始退场
|
||
isAnimating.value = false;
|
||
|
||
setTimeout(() => {
|
||
isVisible.value = false;
|
||
|
||
// 还原之前的焦点
|
||
if (previousActiveElement.value && typeof previousActiveElement.value.focus === "function") {
|
||
previousActiveElement.value.focus();
|
||
}
|
||
|
||
emit("close");
|
||
}, 300);
|
||
});
|
||
}
|
||
|
||
function navigateTo(index: number) {
|
||
if (index >= 0 && index < props.images.length) {
|
||
emit("update:currentIndex", index);
|
||
}
|
||
}
|
||
|
||
function previousImage() {
|
||
if (hasPrevious.value) {
|
||
navigateTo(props.currentIndex - 1);
|
||
}
|
||
}
|
||
|
||
function nextImage() {
|
||
if (hasNext.value) {
|
||
navigateTo(props.currentIndex + 1);
|
||
}
|
||
}
|
||
|
||
function handleKeydown(event: KeyboardEvent) {
|
||
if (!isVisible.value) return;
|
||
|
||
// Tab 陷阱:当按 Tab 时仅在弹窗内部循环焦点
|
||
if (event.key === "Tab") {
|
||
const container = document.querySelector(".image-viewer") as HTMLElement | null;
|
||
if (container) {
|
||
const focusable = Array.from(
|
||
container.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
||
).filter((el) => !el.hasAttribute("disabled"));
|
||
|
||
if (focusable.length) {
|
||
const activeIndex = focusable.indexOf(document.activeElement as HTMLElement);
|
||
if (event.shiftKey) {
|
||
// reverse
|
||
const prev = activeIndex <= 0 ? focusable.length - 1 : activeIndex - 1;
|
||
focusable[prev].focus();
|
||
event.preventDefault();
|
||
} else {
|
||
const next = activeIndex === focusable.length - 1 ? 0 : activeIndex + 1;
|
||
focusable[next].focus();
|
||
event.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
switch (event.key) {
|
||
case "Escape":
|
||
hide();
|
||
break;
|
||
case "ArrowLeft":
|
||
previousImage();
|
||
break;
|
||
case "ArrowRight":
|
||
nextImage();
|
||
break;
|
||
case "+":
|
||
case "=":
|
||
imageScale.value = Math.min(maxScale.value, imageScale.value + ZOOM_STEP);
|
||
event.preventDefault();
|
||
break;
|
||
case "-":
|
||
imageScale.value = Math.max(minScale.value, imageScale.value - ZOOM_STEP);
|
||
event.preventDefault();
|
||
break;
|
||
case "Home":
|
||
navigateTo(0);
|
||
event.preventDefault();
|
||
break;
|
||
case "End":
|
||
navigateTo(props.images.length - 1);
|
||
event.preventDefault();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 处理图片外围点击
|
||
function handleContentClick(event: MouseEvent) {
|
||
if (event.target === event.currentTarget) {
|
||
hide();
|
||
}
|
||
}
|
||
|
||
// 更新窗口大小
|
||
function updateWindowSize() {
|
||
windowSize.value = {
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
};
|
||
}
|
||
|
||
// 缩放功能
|
||
function handleWheel(event: WheelEvent) {
|
||
if (!isVisible.value) return;
|
||
|
||
event.preventDefault();
|
||
const step = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||
const newScale = Math.min(Math.max(imageScale.value + step, ZOOM_MIN), ZOOM_MAX);
|
||
|
||
imageScale.value = newScale;
|
||
}
|
||
// 触摸缩放功能
|
||
let initialDistance = 0;
|
||
let initialScale = 1;
|
||
|
||
function getDistance(touch1: Touch, touch2: Touch) {
|
||
const dx = touch1.clientX - touch2.clientX;
|
||
const dy = touch1.clientY - touch2.clientY;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
// 重置缩放和位置
|
||
function resetZoom() {
|
||
imageScale.value = 1;
|
||
imagePosition.value = { x: 0, y: 0 };
|
||
}
|
||
|
||
// 双击重置
|
||
function handleDoubleClick() {
|
||
resetZoom();
|
||
}
|
||
|
||
// 拖拽功能
|
||
const dragStartPosition = ref({ x: 0, y: 0 });
|
||
const dragStartImagePosition = ref({ x: 0, y: 0 });
|
||
|
||
function handleMouseDown(event: MouseEvent) {
|
||
event.preventDefault();
|
||
isDragging.value = true;
|
||
|
||
// 记录拖拽开始时的位置
|
||
dragStartPosition.value = { x: event.clientX, y: event.clientY };
|
||
dragStartImagePosition.value = { ...imagePosition.value };
|
||
}
|
||
|
||
function handleMouseMove(event: MouseEvent) {
|
||
if (isDragging.value) {
|
||
event.preventDefault();
|
||
|
||
// 计算光标移动的偏移量
|
||
const deltaX = event.clientX - dragStartPosition.value.x;
|
||
const deltaY = event.clientY - dragStartPosition.value.y;
|
||
|
||
// 直接设置图片位置,使点击点跟随光标
|
||
imagePosition.value = {
|
||
x: dragStartImagePosition.value.x + deltaX,
|
||
y: dragStartImagePosition.value.y + deltaY,
|
||
};
|
||
}
|
||
}
|
||
|
||
function handleMouseUp() {
|
||
isDragging.value = false;
|
||
}
|
||
|
||
// 触摸拖拽功能
|
||
let lastTouchPosition = { x: 0, y: 0 };
|
||
let touchStartPosition = { x: 0, y: 0 };
|
||
|
||
function handleTouchStart(event: TouchEvent) {
|
||
if (event.touches.length === 2) {
|
||
event.preventDefault();
|
||
isZooming.value = true;
|
||
initialDistance = getDistance(event.touches[0], event.touches[1]);
|
||
initialScale = imageScale.value;
|
||
} else if (event.touches.length === 1) {
|
||
// 不立即 preventDefault,先记录起始位置,由 handleTouchMove 判断是否是拖拽
|
||
isDragging.value = false; // 初始不认为是拖拽
|
||
touchStartPosition = { x: event.touches[0].clientX, y: event.touches[0].clientY };
|
||
lastTouchPosition = { ...touchStartPosition };
|
||
}
|
||
}
|
||
|
||
function handleTouchMove(event: TouchEvent) {
|
||
if (event.touches.length === 2 && isZooming.value) {
|
||
event.preventDefault();
|
||
const currentDistance = getDistance(event.touches[0], event.touches[1]);
|
||
const scaleFactor = currentDistance / initialDistance;
|
||
const newScale = Math.min(Math.max(initialScale * scaleFactor, ZOOM_MIN), ZOOM_MAX);
|
||
|
||
imageScale.value = newScale;
|
||
} else if (event.touches.length === 1) {
|
||
const currentTouch = event.touches[0];
|
||
const deltaX = currentTouch.clientX - touchStartPosition.x;
|
||
const deltaY = currentTouch.clientY - touchStartPosition.y;
|
||
|
||
// 判断是否超过阈值,决定是否作为拖拽
|
||
if (Math.abs(deltaX) > TOUCH_MOVE_THRESHOLD || Math.abs(deltaY) > TOUCH_MOVE_THRESHOLD) {
|
||
if (!isDragging.value) {
|
||
// 第一次超过阈值时,标记为拖拽并阻止默认行为
|
||
isDragging.value = true;
|
||
event.preventDefault();
|
||
}
|
||
|
||
// 执行拖拽逻辑
|
||
if (isDragging.value) {
|
||
event.preventDefault();
|
||
const moveDeltaX = currentTouch.clientX - lastTouchPosition.x;
|
||
const moveDeltaY = currentTouch.clientY - lastTouchPosition.y;
|
||
|
||
imagePosition.value = {
|
||
x: imagePosition.value.x + moveDeltaX,
|
||
y: imagePosition.value.y + moveDeltaY,
|
||
};
|
||
|
||
lastTouchPosition = { x: currentTouch.clientX, y: currentTouch.clientY };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleTouchEnd(event: TouchEvent) {
|
||
if (event.touches.length < 2) {
|
||
isZooming.value = false;
|
||
}
|
||
|
||
if (event.touches.length === 0) {
|
||
// 检查是否是纯点击(未产生拖拽)
|
||
if (!isDragging.value) {
|
||
// 如果没有拖拽,说明是纯点击。判断点击位置是否在图片外部
|
||
const eventTarget = event.target as HTMLElement;
|
||
const contentContainer = eventTarget.closest(".image-viewer__content");
|
||
|
||
// 如果触摸发生在 content 容器但不是图片本身,关闭查看器
|
||
if (contentContainer && eventTarget === contentContainer) {
|
||
hide();
|
||
}
|
||
}
|
||
|
||
isDragging.value = false;
|
||
}
|
||
}
|
||
|
||
// 切换图片时重置缩放和位置
|
||
watch(
|
||
() => props.currentIndex,
|
||
() => {
|
||
resetZoom();
|
||
}
|
||
);
|
||
|
||
onMounted(() => {
|
||
show();
|
||
document.addEventListener("keydown", handleKeydown);
|
||
window.addEventListener("resize", updateWindowSize);
|
||
updateWindowSize();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener("keydown", handleKeydown);
|
||
window.removeEventListener("resize", updateWindowSize);
|
||
document.body.style.overflow = "";
|
||
});
|
||
|
||
defineExpose({
|
||
show,
|
||
hide,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
v-if="isVisible"
|
||
class="image-viewer"
|
||
:class="{ animating: isAnimating }"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="图片查看器"
|
||
tabindex="-1"
|
||
>
|
||
<!-- 控件 -->
|
||
<MaterialButton @click="hide" aria-label="关闭图片查看器" class="btn-close" icon="close" color="text" size="m" />
|
||
<MaterialButton
|
||
v-if="hasPrevious"
|
||
@click="previousImage"
|
||
aria-label="上一张图片"
|
||
class="btn-nav prev"
|
||
icon="chevron_left"
|
||
size="m"
|
||
/>
|
||
<MaterialButton
|
||
v-if="hasNext"
|
||
@click="nextImage"
|
||
aria-label="下一张图片"
|
||
class="btn-nav next"
|
||
icon="chevron_right"
|
||
size="m"
|
||
/>
|
||
|
||
<!-- 图片主体 -->
|
||
<div
|
||
class="content"
|
||
@click="handleContentClick"
|
||
@wheel="handleWheel"
|
||
@touchstart="handleTouchStart"
|
||
@touchmove="handleTouchMove"
|
||
@touchend="handleTouchEnd"
|
||
>
|
||
<img
|
||
:src="currentImage"
|
||
:alt="`图片 ${currentIndex + 1} / ${images.length}`"
|
||
class="content-image"
|
||
:class="{
|
||
transitioning: imageTransition,
|
||
notransition: isDragging || isZooming,
|
||
}"
|
||
@dblclick="handleDoubleClick"
|
||
@mousedown="handleMouseDown"
|
||
@mousemove="handleMouseMove"
|
||
@mouseup="handleMouseUp"
|
||
@mouseleave="handleMouseUp"
|
||
:style="{
|
||
transform: isAnimating
|
||
? `scale(${imageScale}) translate(${imagePosition.x}px, ${imagePosition.y}px)`
|
||
: `scale(${initialTransform.scale}) translate(${initialTransform.translateX}px, ${initialTransform.translateY}px)`,
|
||
opacity: imageTransition ? 0.6 : isAnimating ? 1 : 0,
|
||
cursor: imageScale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in',
|
||
maxWidth: `${windowSize.width * 0.85}px`,
|
||
maxHeight: `${windowSize.height * 0.75}px`,
|
||
}"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 缩略图导航 -->
|
||
<p>{{ currentIndex + 1 }} / {{ images.length }}</p>
|
||
<div class="thumbnails" v-if="images.length > 1">
|
||
<button
|
||
v-for="(image, index) in images"
|
||
:key="index"
|
||
class="thumbnail"
|
||
:class="{ 'thumbnail active': index === currentIndex }"
|
||
@click="navigateTo(index)"
|
||
:aria-label="`查看图片 ${index + 1}`"
|
||
>
|
||
<img :src="image" :alt="`缩略图 ${index + 1}`" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
@use "../styles/mixin";
|
||
|
||
.image-viewer {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
|
||
height: 100%;
|
||
width: 100%;
|
||
|
||
background-color: #0000008a;
|
||
|
||
opacity: 0;
|
||
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
|
||
z-index: 9999;
|
||
|
||
&.animating {
|
||
opacity: 1;
|
||
}
|
||
|
||
.btn-close,
|
||
.btn-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
position: absolute !important;
|
||
|
||
z-index: 3;
|
||
}
|
||
|
||
.btn-close {
|
||
right: 20px;
|
||
top: 20px;
|
||
}
|
||
|
||
.btn-nav {
|
||
top: 50%;
|
||
|
||
&.prev {
|
||
left: 20px;
|
||
}
|
||
|
||
&.next {
|
||
right: 20px;
|
||
}
|
||
}
|
||
|
||
.content {
|
||
display: flex;
|
||
align-items: center;
|
||
flex: 1;
|
||
justify-content: center;
|
||
|
||
width: 100%;
|
||
|
||
padding-block-start: 5vh;
|
||
|
||
z-index: 2;
|
||
}
|
||
|
||
.content-image {
|
||
object-fit: contain;
|
||
opacity: 0;
|
||
transition: transform var(--md-sys-motion-spring-slow-spatial-duration) var(--md-sys-motion-spring-slow-spatial),
|
||
opacity var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
|
||
|
||
.animating & {
|
||
opacity: 1;
|
||
}
|
||
|
||
&.transitioning {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
&.notransition {
|
||
transition: none !important;
|
||
}
|
||
}
|
||
|
||
.thumbnails {
|
||
display: flex;
|
||
gap: 8px;
|
||
|
||
max-width: calc(100% - 72px);
|
||
|
||
margin-block-end: 5vh;
|
||
margin-block-start: 2.5vh;
|
||
padding: 10px;
|
||
|
||
overflow-x: auto;
|
||
z-index: 3;
|
||
}
|
||
|
||
.thumbnail {
|
||
flex-shrink: 0;
|
||
|
||
width: 72px;
|
||
height: 72px;
|
||
|
||
padding: 0px;
|
||
|
||
border: 0px;
|
||
border-radius: var(--md-sys-shape-corner-medium);
|
||
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
|
||
&.active {
|
||
@include mixin.focus-ring($thickness: 1, $offset: 2);
|
||
|
||
outline-color: var(--md-sys-color-on-surface-variant) !important;
|
||
}
|
||
|
||
&:focus-visible {
|
||
@include mixin.focus-ring($thickness: 2, $offset: 2);
|
||
}
|
||
|
||
img {
|
||
height: 100%;
|
||
width: 100%;
|
||
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
}
|
||
|
||
@media screen and (max-width: 1600px) {
|
||
}
|
||
|
||
@media screen and (max-width: 1200px) {
|
||
}
|
||
|
||
@media screen and (max-width: 840px) {
|
||
}
|
||
|
||
@media screen and (max-width: 600px) {
|
||
}
|
||
</style>
|