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:
591
.vitepress/theme/components/ImageViewer.vue
Normal file
591
.vitepress/theme/components/ImageViewer.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user