1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-05 23:32:45 +08:00

feat: add image viewer component with zoom and navigation

This commit is contained in:
2025-11-11 22:55:52 +08:00
parent 4457b2539e
commit c6c5cf260a
3 changed files with 660 additions and 6 deletions

View File

@@ -0,0 +1,591 @@
<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>

View File

@@ -1,9 +1,11 @@
import type { Theme } from "vitepress";
import Layout from "./layouts/Default.vue";
import Button from "./components/Button.vue";
import Footer from "./components/Footer.vue";
import Header from "./components/Header.vue";
import Layout from "./layouts/Default.vue";
import ImageViewer from "./components/ImageViewer.vue";
import PageIndicator from "./components/PageIndicator.vue";
import PrevNext from "./components/PrevNext.vue";
import ScrollToTop from "./components/ScrollToTop.vue";
@@ -16,6 +18,7 @@ export default {
enhanceApp({ app }) {
app.component("Footer", Footer);
app.component("Header", Header);
app.component("ImageViewer", ImageViewer);
app.component("MainLayout", Layout);
app.component("MaterialButton", Button);
app.component("PageIndicator", PageIndicator);

View File

@@ -1,8 +1,41 @@
<script setup lang="ts">
import Header from "../components/Header.vue";
import PageIndicator from "../components/PageIndicator.vue";
import PrevNext from "../components/PrevNext.vue";
import { onMounted } from "vue";
import { ref, onMounted } from "vue";
const showImageViewer = ref(false);
const currentImageIndex = ref(0);
const articleImages = ref<string[]>([]);
const imageOriginPosition = ref({ x: 0, y: 0, width: 0, height: 0 });
function openImageViewer(index: number, event: MouseEvent) {
const contentElement = document.querySelector("#article-content");
if (contentElement) {
const images = Array.from(contentElement.querySelectorAll("img"))
.map((img) => img.src)
.filter((src) => src && !src.includes("data:"));
articleImages.value = images;
currentImageIndex.value = index;
const target = event.target as HTMLElement;
const rect = target.getBoundingClientRect();
imageOriginPosition.value = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
width: rect.width,
height: rect.height,
};
showImageViewer.value = true;
}
}
function closeImageViewer() {
showImageViewer.value = false;
}
function updateCurrentImageIndex(index: number) {
currentImageIndex.value = index;
}
function copyAnchorLink(this: HTMLElement) {
const anchor = this as HTMLAnchorElement;
@@ -60,14 +93,26 @@ if (typeof window !== "undefined") {
anchor.addEventListener("click", copyAnchorLink);
});
function setupImageClickListeners() {
const contentElement = document.querySelector("#article-content");
if (contentElement) {
const images = contentElement.querySelectorAll("img");
images.forEach((img, index) => {
(img as HTMLImageElement).onclick = (event: MouseEvent) => openImageViewer(index, event);
});
}
}
ulCustomBullets();
olCountAttributes();
setupImageClickListeners();
window.addEventListener("resize", ulCustomBullets);
const observer = new MutationObserver(() => {
ulCustomBullets();
olCountAttributes();
setupImageClickListeners();
});
const contentElement = document.querySelector("#article-content");
@@ -91,6 +136,14 @@ if (typeof window !== "undefined") {
<section id="article-indicator">
<PageIndicator />
</section>
<ImageViewer
v-if="showImageViewer"
:images="articleImages"
:current-index="currentImageIndex"
:origin-position="imageOriginPosition"
@close="closeImageViewer"
@update:current-index="updateCurrentImageIndex"
/>
</template>
<style lang="scss">
@@ -305,7 +358,8 @@ section {
border-radius: var(--md-sys-shape-corner-large-increased);
cursor: pointer;
transition: var(--md-sys-motion-spring-slow-spatial-standard-duration) var(--md-sys-motion-spring-slow-spatial-standard);
transition: var(--md-sys-motion-spring-slow-spatial-standard-duration)
var(--md-sys-motion-spring-slow-spatial-standard);
user-select: none;
-moz-user-select: none;
@@ -685,6 +739,12 @@ section {
margin-inline: 3px;
border-radius: var(--md-sys-shape-corner-small);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}