- Created a new CSS file for font imports and defined font families for base, mono, strong, em, and h1 elements. - Added a new JavaScript file to extend the default VitePress theme, incorporating a custom layout and a Vue component for enhanced functionality. - Integrated Mermaid diagram support and included the necessary styles for it.
1455 lines
39 KiB
Vue
1455 lines
39 KiB
Vue
<template>
|
||
<!-- 正常模式 -->
|
||
<div
|
||
v-show="!isFullscreen"
|
||
class="zoom-container"
|
||
ref="containerRef"
|
||
v-bind="attrs"
|
||
:style="containerStyle"
|
||
@wheel.prevent="handleWheel"
|
||
@mousedown="handleMouseDown"
|
||
@touchstart="handleTouchStart"
|
||
@touchmove="handleTouchMove"
|
||
@touchend="handleTouchEnd"
|
||
>
|
||
<canvas ref="canvasRef" class="zoom-canvas"></canvas>
|
||
|
||
<div class="loading-indicator" v-if="isLoading">加载中...</div>
|
||
|
||
<!-- 小地图 -->
|
||
<div
|
||
class="minimap"
|
||
ref="minimapRef"
|
||
@mousedown="handleMinimapMouseDown"
|
||
@wheel.stop
|
||
:style="minimapStyle"
|
||
>
|
||
<canvas ref="minimapCanvasRef" class="minimap-canvas"></canvas>
|
||
<div class="minimap-viewport" :style="minimapViewportStyle"></div>
|
||
</div>
|
||
|
||
<div class="zoom-controls" @wheel.stop @mousedown.stop>
|
||
<div class="zoom-tool-group">
|
||
<button @click="zoomIn" title="放大" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21l-4.35-4.35M11 8v6m-3-3h6"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
<button @click="zoomOut" title="缩小" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21l-4.35-4.35M8 11h6"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
<button @click="resetZoom" title="重置" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||
></path>
|
||
<path d="M3 3v5h5"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="zoom-divider-v"></div>
|
||
<div class="zoom-tool-group">
|
||
<button
|
||
@click="toggleFullscreen"
|
||
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||
class="zoom-tool-btn"
|
||
>
|
||
<svg
|
||
v-if="!isFullscreen"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3m8 0h3a2 2 0 0 0 2-2v-3"
|
||
></path>
|
||
</svg>
|
||
<svg
|
||
v-else
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 全屏模式 -->
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="isFullscreen"
|
||
class="fullscreen-overlay"
|
||
@wheel.prevent="handleWheel"
|
||
@mousedown="handleMouseDown"
|
||
@touchstart="handleTouchStart"
|
||
@touchmove="handleTouchMove"
|
||
@touchend="handleTouchEnd"
|
||
>
|
||
<canvas ref="fullscreenCanvasRef" class="zoom-canvas"></canvas>
|
||
|
||
<!-- 小地图 -->
|
||
<div
|
||
class="minimap"
|
||
ref="fullscreenMinimapRef"
|
||
@mousedown="handleMinimapMouseDown"
|
||
@wheel.stop
|
||
:style="minimapStyle"
|
||
>
|
||
<canvas
|
||
ref="fullscreenMinimapCanvasRef"
|
||
class="minimap-canvas"
|
||
></canvas>
|
||
<div class="minimap-viewport" :style="minimapViewportStyle"></div>
|
||
</div>
|
||
|
||
<div class="zoom-controls" @wheel.stop @mousedown.stop>
|
||
<div class="zoom-tool-group">
|
||
<button @click="zoomIn" title="放大" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21l-4.35-4.35M11 8v6m-3-3h6"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
<button @click="zoomOut" title="缩小" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21l-4.35-4.35M8 11h6"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
<button @click="resetZoom" title="重置" class="zoom-tool-btn">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<g
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||
></path>
|
||
<path d="M3 3v5h5"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="zoom-divider-v"></div>
|
||
<div class="zoom-tool-group">
|
||
<button
|
||
@click="toggleFullscreen"
|
||
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||
class="zoom-tool-btn"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||
aria-hidden="true"
|
||
role="img"
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {
|
||
ref,
|
||
computed,
|
||
onMounted,
|
||
onUnmounted,
|
||
watch,
|
||
nextTick,
|
||
useAttrs,
|
||
} from "vue";
|
||
import { withBase } from "vitepress";
|
||
|
||
// 禁用自动属性继承,手动控制 style 应用
|
||
defineOptions({
|
||
inheritAttrs: false,
|
||
});
|
||
|
||
const attrs = useAttrs();
|
||
|
||
const props = defineProps({
|
||
src: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
minZoom: {
|
||
type: Number,
|
||
default: 1.0, // 允许缩小到 10%
|
||
},
|
||
maxZoom: {
|
||
type: Number,
|
||
default: 100,
|
||
},
|
||
zoomStep: {
|
||
type: Number,
|
||
default: 1.2,
|
||
},
|
||
});
|
||
|
||
const containerRef = ref(null);
|
||
const canvasRef = ref(null);
|
||
const fullscreenCanvasRef = ref(null);
|
||
|
||
const minimapRef = ref(null);
|
||
const minimapCanvasRef = ref(null);
|
||
const fullscreenMinimapRef = ref(null);
|
||
const fullscreenMinimapCanvasRef = ref(null);
|
||
|
||
const isLoading = ref(true);
|
||
const isFullscreen = ref(false);
|
||
|
||
// 小地图相关
|
||
let minimapCtx = null;
|
||
let fullscreenMinimapCtx = null;
|
||
let minimapAnimationFrameId = null;
|
||
const minimapStyle = ref({
|
||
width: "150px",
|
||
height: "100px",
|
||
});
|
||
|
||
// 图片宽高比(用于容器默认高度)
|
||
const imageAspectRatio = ref(null);
|
||
|
||
// 容器样式(包含动态宽高比)
|
||
const containerStyle = computed(() => {
|
||
if (imageAspectRatio.value) {
|
||
return {
|
||
height: "auto",
|
||
aspectRatio: imageAspectRatio.value,
|
||
};
|
||
}
|
||
return { height: "400px" };
|
||
});
|
||
|
||
// 缩放和平移状态
|
||
const scale = ref(1);
|
||
const translateX = ref(0);
|
||
const translateY = ref(0);
|
||
|
||
// 拖拽状态
|
||
const isDragging = ref(false);
|
||
const dragStartX = ref(0);
|
||
const dragStartY = ref(0);
|
||
const initialTranslateX = ref(0);
|
||
const initialTranslateY = ref(0);
|
||
|
||
// 图片和Canvas相关
|
||
let ctx = null;
|
||
let fullscreenCtx = null;
|
||
let originalImage = null;
|
||
let animationFrameId = null;
|
||
|
||
// 多分辨率图片缓存
|
||
const imageCache = ref(new Map()); // 缩放级别 -> 图片对象
|
||
const currentZoomLevel = ref(null);
|
||
const debounceTimer = ref(null);
|
||
|
||
// 定义缩放级别和对应的分辨率
|
||
const zoomLevels = [
|
||
{ maxScale: 0.25, resolution: 512 }, // 极小缩放:512px
|
||
{ maxScale: 0.5, resolution: 1024 }, // 小缩放:1024px
|
||
{ maxScale: 1, resolution: 1920 }, // 原始大小:1920px
|
||
{ maxScale: 2, resolution: 2560 }, // 轻微放大:2560px
|
||
{ maxScale: 5, resolution: 3840 }, // 中等放大:3840px
|
||
{ maxScale: Infinity, resolution: null }, // 大幅放大:使用原图
|
||
];
|
||
|
||
// 根据当前缩放获取合适的缩放级别
|
||
const getZoomLevel = (currentScale) => {
|
||
for (let i = 0; i < zoomLevels.length; i++) {
|
||
if (currentScale <= zoomLevels[i].maxScale) {
|
||
return i;
|
||
}
|
||
}
|
||
return zoomLevels.length - 1;
|
||
};
|
||
|
||
// 创建降采样图片
|
||
const createDownsampledImage = (targetResolution) => {
|
||
return new Promise((resolve, reject) => {
|
||
if (!originalImage) {
|
||
reject(new Error("原始图片未加载"));
|
||
return;
|
||
}
|
||
|
||
// 如果目标分辨率大于等于原图,直接使用原图
|
||
if (
|
||
targetResolution &&
|
||
targetResolution >= Math.max(originalImage.width, originalImage.height)
|
||
) {
|
||
resolve(originalImage);
|
||
return;
|
||
}
|
||
|
||
// 计算降采样后的尺寸(保持宽高比)
|
||
const aspectRatio = originalImage.width / originalImage.height;
|
||
let targetWidth, targetHeight;
|
||
|
||
if (aspectRatio > 1) {
|
||
targetWidth = targetResolution;
|
||
targetHeight = Math.floor(targetResolution / aspectRatio);
|
||
} else {
|
||
targetHeight = targetResolution;
|
||
targetWidth = Math.floor(targetResolution * aspectRatio);
|
||
}
|
||
|
||
// 创建离屏Canvas进行降采样
|
||
const offscreenCanvas = document.createElement("canvas");
|
||
offscreenCanvas.width = targetWidth;
|
||
offscreenCanvas.height = targetHeight;
|
||
const offCtx = offscreenCanvas.getContext("2d");
|
||
|
||
// 高质量降采样
|
||
offCtx.imageSmoothingEnabled = true;
|
||
offCtx.imageSmoothingQuality = "high";
|
||
offCtx.drawImage(originalImage, 0, 0, targetWidth, targetHeight);
|
||
|
||
// 创建新的图片对象
|
||
const downsampledImg = new Image();
|
||
downsampledImg.onload = () => resolve(downsampledImg);
|
||
downsampledImg.onerror = reject;
|
||
downsampledImg.src = offscreenCanvas.toDataURL("image/png");
|
||
});
|
||
};
|
||
|
||
// 更新当前使用的图片(带防抖)
|
||
const updateCurrentImage = async () => {
|
||
const newZoomLevel = getZoomLevel(scale.value);
|
||
|
||
// 如果缩放级别没有变化,不需要更新
|
||
if (newZoomLevel === currentZoomLevel.value) return;
|
||
|
||
currentZoomLevel.value = newZoomLevel;
|
||
const levelConfig = zoomLevels[newZoomLevel];
|
||
|
||
// 检查缓存中是否已有该分辨率的图片
|
||
if (imageCache.value.has(newZoomLevel)) {
|
||
render();
|
||
return;
|
||
}
|
||
|
||
// 如果需要使用原图,直接设置
|
||
if (levelConfig.resolution === null) {
|
||
imageCache.value.set(newZoomLevel, originalImage);
|
||
render();
|
||
return;
|
||
}
|
||
|
||
// 创建降采样图片
|
||
try {
|
||
const downsampledImg = await createDownsampledImage(levelConfig.resolution);
|
||
imageCache.value.set(newZoomLevel, downsampledImg);
|
||
render();
|
||
} catch (error) {
|
||
console.error("创建降采样图片失败:", error);
|
||
}
|
||
};
|
||
|
||
// 更新小地图尺寸以匹配图片宽高比(宽度和高度都调整,但保持图片比例)
|
||
const updateMinimapSize = () => {
|
||
if (!originalImage) return;
|
||
|
||
const aspectRatio = originalImage.width / originalImage.height;
|
||
|
||
// 设定一个基础尺寸(假设是正方形时的大小)
|
||
const baseSize = 120;
|
||
|
||
let width, height;
|
||
if (aspectRatio > 1) {
|
||
// 横向图片:宽度固定,高度按比例
|
||
width = baseSize;
|
||
height = baseSize / aspectRatio;
|
||
} else {
|
||
// 纵向图片:高度固定,宽度按比例
|
||
height = baseSize;
|
||
width = baseSize * aspectRatio;
|
||
}
|
||
|
||
// 限制最大尺寸,避免小地图过大
|
||
const maxSize = 200;
|
||
if (width > maxSize || height > maxSize) {
|
||
if (aspectRatio > 1) {
|
||
width = maxSize;
|
||
height = maxSize / aspectRatio;
|
||
} else {
|
||
height = maxSize;
|
||
width = maxSize * aspectRatio;
|
||
}
|
||
}
|
||
|
||
// 限制最小尺寸
|
||
const minSize = 60;
|
||
width = Math.max(width, minSize);
|
||
height = Math.max(height, minSize);
|
||
|
||
minimapStyle.value = {
|
||
width: `${width}px`,
|
||
height: `${height}px`,
|
||
};
|
||
|
||
// 等待 DOM 更新后重新初始化小地图 canvas
|
||
nextTick(() => {
|
||
initMinimapCanvas(minimapCanvasRef.value, minimapRef.value, minimapCtx);
|
||
if (isFullscreen.value) {
|
||
initMinimapCanvas(
|
||
fullscreenMinimapCanvasRef.value,
|
||
fullscreenMinimapRef.value,
|
||
fullscreenMinimapCtx,
|
||
);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 获取当前应该使用的图片
|
||
const getCurrentImage = () => {
|
||
const level =
|
||
currentZoomLevel.value !== null
|
||
? currentZoomLevel.value
|
||
: getZoomLevel(scale.value);
|
||
return imageCache.value.get(level) || originalImage;
|
||
};
|
||
|
||
// 加载图片
|
||
const loadImage = () => {
|
||
isLoading.value = true;
|
||
imageCache.value.clear();
|
||
currentZoomLevel.value = null;
|
||
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
|
||
img.onload = () => {
|
||
originalImage = img;
|
||
isLoading.value = false;
|
||
|
||
// 设置图片宽高比
|
||
imageAspectRatio.value = img.naturalWidth / img.naturalHeight;
|
||
|
||
// 初始化缓存:将原图作为最高级别缓存
|
||
const highestLevel = zoomLevels.length - 1;
|
||
imageCache.value.set(highestLevel, img);
|
||
currentZoomLevel.value = highestLevel;
|
||
|
||
nextTick(() => {
|
||
resizeCanvas();
|
||
// 更新小地图尺寸以匹配图片宽高比
|
||
updateMinimapSize();
|
||
// 预生成第一个缩放级别的图片
|
||
updateCurrentImage();
|
||
});
|
||
};
|
||
|
||
img.onerror = () => {
|
||
console.error("图片加载失败");
|
||
isLoading.value = false;
|
||
};
|
||
|
||
img.src = withBase(props.src);
|
||
};
|
||
|
||
// 初始化小地图 canvas 的尺寸和 context
|
||
const initMinimapCanvas = (canvas, minimap, context) => {
|
||
if (!canvas || !minimap || !context) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = minimap.getBoundingClientRect();
|
||
|
||
// 重置变换矩阵
|
||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||
|
||
// 设置 canvas 实际分辨率
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
|
||
// 应用设备像素比缩放
|
||
context.scale(dpr, dpr);
|
||
};
|
||
|
||
// 调整Canvas尺寸
|
||
const resizeCanvas = () => {
|
||
if (!canvasRef.value || !containerRef.value) return;
|
||
|
||
const container = containerRef.value;
|
||
const canvas = canvasRef.value;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
// 重置变换矩阵,确保新的scale不会叠加
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
|
||
// 强制重新获取容器尺寸
|
||
const rect = container.getBoundingClientRect();
|
||
|
||
// 设置Canvas显示尺寸
|
||
canvas.style.width = "100%";
|
||
canvas.style.height = "100%";
|
||
|
||
// 设置Canvas实际分辨率
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
|
||
// 应用设备像素比缩放
|
||
ctx.scale(dpr, dpr);
|
||
|
||
// 如果在全屏模式,也需要调整全屏 canvas
|
||
if (isFullscreen.value && fullscreenCanvasRef.value) {
|
||
const fullscreenCanvas = fullscreenCanvasRef.value;
|
||
fullscreenCanvas.width = window.innerWidth * dpr;
|
||
fullscreenCanvas.height = window.innerHeight * dpr;
|
||
|
||
if (fullscreenCtx) {
|
||
fullscreenCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
fullscreenCtx.scale(dpr, dpr);
|
||
}
|
||
}
|
||
|
||
render();
|
||
};
|
||
|
||
// 渲染函数
|
||
const render = () => {
|
||
const currentImage = getCurrentImage();
|
||
if (!currentImage) return;
|
||
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(() => {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
// 渲染到正常模式的 canvas
|
||
if (canvasRef.value && containerRef.value) {
|
||
const canvas = canvasRef.value;
|
||
const container = containerRef.value;
|
||
|
||
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
||
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
|
||
renderToCanvas(
|
||
ctx,
|
||
canvas,
|
||
containerWidth,
|
||
containerHeight,
|
||
currentImage,
|
||
dpr,
|
||
);
|
||
}
|
||
|
||
// 渲染到全屏模式的 canvas
|
||
if (fullscreenCanvasRef.value && isFullscreen.value) {
|
||
const canvas = fullscreenCanvasRef.value;
|
||
const containerWidth = window.innerWidth;
|
||
const containerHeight = window.innerHeight;
|
||
|
||
// 确保 fullscreenCanvasRef 有 context
|
||
if (!fullscreenCtx) {
|
||
fullscreenCtx = canvas.getContext("2d");
|
||
}
|
||
|
||
if (fullscreenCtx) {
|
||
fullscreenCtx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
||
renderToCanvas(
|
||
fullscreenCtx,
|
||
canvas,
|
||
containerWidth,
|
||
containerHeight,
|
||
currentImage,
|
||
dpr,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 同时渲染小地图
|
||
renderMinimap();
|
||
});
|
||
};
|
||
|
||
// 渲染到指定 canvas
|
||
const renderToCanvas = (
|
||
context,
|
||
canvas,
|
||
containerWidth,
|
||
containerHeight,
|
||
currentImage,
|
||
dpr,
|
||
) => {
|
||
const imgAspectRatio = currentImage.width / currentImage.height;
|
||
const containerAspectRatio = containerWidth / containerHeight;
|
||
|
||
// 使用 contain 模式:图片完整显示在容器内,可能有空白边
|
||
let renderWidth, renderHeight;
|
||
if (imgAspectRatio > containerAspectRatio) {
|
||
// 图片更宽,以宽度为准(contain)
|
||
renderWidth = containerWidth;
|
||
renderHeight = containerWidth / imgAspectRatio;
|
||
} else {
|
||
// 图片更高,以高度为准(contain)
|
||
renderHeight = containerHeight;
|
||
renderWidth = containerHeight * imgAspectRatio;
|
||
}
|
||
|
||
context.save();
|
||
context.translate(containerWidth / 2, containerHeight / 2);
|
||
context.scale(scale.value, scale.value);
|
||
context.translate(translateX.value, translateY.value);
|
||
|
||
context.drawImage(
|
||
currentImage,
|
||
-renderWidth / 2,
|
||
-renderHeight / 2,
|
||
renderWidth,
|
||
renderHeight,
|
||
);
|
||
|
||
context.restore();
|
||
};
|
||
|
||
// 触发图片更新(带防抖)
|
||
const triggerImageUpdate = () => {
|
||
if (debounceTimer.value) {
|
||
clearTimeout(debounceTimer.value);
|
||
}
|
||
|
||
debounceTimer.value = setTimeout(() => {
|
||
updateCurrentImage();
|
||
}, 100); // 100ms防抖延迟
|
||
};
|
||
|
||
// 小地图视口样式
|
||
const minimapViewportStyle = computed(() => {
|
||
// 根据当前模式选择对应的容器和小地图
|
||
const container = isFullscreen.value
|
||
? { clientWidth: window.innerWidth, clientHeight: window.innerHeight }
|
||
: containerRef.value;
|
||
const minimap = isFullscreen.value
|
||
? fullscreenMinimapRef.value
|
||
: minimapRef.value;
|
||
|
||
if (!container || !minimap) return {};
|
||
|
||
const currentImage = getCurrentImage();
|
||
if (!currentImage) return {};
|
||
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
const minimapWidth = minimap.clientWidth;
|
||
const minimapHeight = minimap.clientHeight;
|
||
|
||
// 计算图片在容器中的基础显示尺寸(scale=1时)
|
||
const imgAspectRatio = currentImage.width / currentImage.height;
|
||
const containerAspectRatio = containerWidth / containerHeight;
|
||
|
||
// 使用 contain 模式计算基础尺寸
|
||
let baseWidth, baseHeight;
|
||
if (imgAspectRatio > containerAspectRatio) {
|
||
// 图片更宽,以宽度为准(contain)
|
||
baseWidth = containerWidth;
|
||
baseHeight = containerWidth / imgAspectRatio;
|
||
} else {
|
||
// 图片更高,以高度为准(contain)
|
||
baseHeight = containerHeight;
|
||
baseWidth = containerHeight * imgAspectRatio;
|
||
}
|
||
|
||
// 当前缩放
|
||
const currentScale = scale.value;
|
||
const scaledWidth = baseWidth * currentScale;
|
||
const scaledHeight = baseHeight * currentScale;
|
||
|
||
// 计算小地图中图片的显示尺寸
|
||
const minimapAspectRatio = minimapWidth / minimapHeight;
|
||
|
||
let minimapImgWidth, minimapImgHeight;
|
||
if (imgAspectRatio > minimapAspectRatio) {
|
||
minimapImgWidth = minimapWidth;
|
||
minimapImgHeight = minimapWidth / imgAspectRatio;
|
||
} else {
|
||
minimapImgHeight = minimapHeight;
|
||
minimapImgWidth = minimapHeight * imgAspectRatio;
|
||
}
|
||
|
||
const minimapImgOffsetX = (minimapWidth - minimapImgWidth) / 2;
|
||
const minimapImgOffsetY = (minimapHeight - minimapImgHeight) / 2;
|
||
|
||
// 小地图相对于缩放后图片的比例
|
||
const minimapToScaledRatio = minimapImgWidth / scaledWidth;
|
||
|
||
// 蓝框尺寸:容器在小地图上的对应尺寸(保持容器高宽比)
|
||
const boxWidth = containerWidth * minimapToScaledRatio;
|
||
const boxHeight = containerHeight * minimapToScaledRatio;
|
||
|
||
// 计算图片中心相对于容器中心的偏移
|
||
const imageOffsetX = translateX.value * currentScale;
|
||
const imageOffsetY = translateY.value * currentScale;
|
||
|
||
// 蓝框中心 = 图片中心 - 图片偏移
|
||
const boxCenterX =
|
||
minimapImgOffsetX +
|
||
minimapImgWidth / 2 -
|
||
imageOffsetX * minimapToScaledRatio;
|
||
const boxCenterY =
|
||
minimapImgOffsetY +
|
||
minimapImgHeight / 2 -
|
||
imageOffsetY * minimapToScaledRatio;
|
||
|
||
// 蓝框左上角
|
||
let boxLeft = boxCenterX - boxWidth / 2;
|
||
let boxTop = boxCenterY - boxHeight / 2;
|
||
let boxRight = boxLeft + boxWidth;
|
||
let boxBottom = boxTop + boxHeight;
|
||
|
||
// 裁剪超出小地图图片区域的部分
|
||
boxLeft = Math.max(boxLeft, minimapImgOffsetX);
|
||
boxTop = Math.max(boxTop, minimapImgOffsetY);
|
||
boxRight = Math.min(boxRight, minimapImgOffsetX + minimapImgWidth);
|
||
boxBottom = Math.min(boxBottom, minimapImgOffsetY + minimapImgHeight);
|
||
|
||
// 如果完全超出,隐藏
|
||
if (boxLeft >= boxRight || boxTop >= boxBottom) {
|
||
return { display: "none" };
|
||
}
|
||
|
||
return {
|
||
left: `${boxLeft}px`,
|
||
top: `${boxTop}px`,
|
||
width: `${boxRight - boxLeft}px`,
|
||
height: `${boxBottom - boxTop}px`,
|
||
};
|
||
});
|
||
|
||
// 渲染小地图
|
||
const renderMinimap = () => {
|
||
// 根据当前模式选择对应的小地图 canvas 和 ref
|
||
const canvas = isFullscreen.value
|
||
? fullscreenMinimapCanvasRef.value
|
||
: minimapCanvasRef.value;
|
||
const minimap = isFullscreen.value
|
||
? fullscreenMinimapRef.value
|
||
: minimapRef.value;
|
||
const ctx = isFullscreen.value ? fullscreenMinimapCtx : minimapCtx;
|
||
|
||
if (!ctx || !canvas || !minimap) return;
|
||
|
||
const currentImage = getCurrentImage();
|
||
if (!currentImage) return;
|
||
|
||
if (minimapAnimationFrameId) {
|
||
cancelAnimationFrame(minimapAnimationFrameId);
|
||
}
|
||
|
||
minimapAnimationFrameId = requestAnimationFrame(() => {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = minimap.getBoundingClientRect();
|
||
|
||
// 清空小地图(注意:canvas 已经在 initMinimapCanvas 中设置了正确的尺寸和 scale)
|
||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||
|
||
// 计算小地图中图片的显示尺寸(保持宽高比)
|
||
const imgAspectRatio = currentImage.width / currentImage.height;
|
||
const minimapAspectRatio = rect.width / rect.height;
|
||
|
||
let minimapImgWidth, minimapImgHeight;
|
||
if (imgAspectRatio > minimapAspectRatio) {
|
||
minimapImgWidth = rect.width;
|
||
minimapImgHeight = rect.width / imgAspectRatio;
|
||
} else {
|
||
minimapImgHeight = rect.height;
|
||
minimapImgWidth = rect.height * imgAspectRatio;
|
||
}
|
||
|
||
// 居中绘制缩略图
|
||
const offsetX = (rect.width - minimapImgWidth) / 2;
|
||
const offsetY = (rect.height - minimapImgHeight) / 2;
|
||
|
||
ctx.drawImage(
|
||
currentImage,
|
||
0,
|
||
0,
|
||
currentImage.width,
|
||
currentImage.height,
|
||
offsetX,
|
||
offsetY,
|
||
minimapImgWidth,
|
||
minimapImgHeight,
|
||
);
|
||
});
|
||
};
|
||
|
||
// 小地图鼠标交互
|
||
const handleMinimapMouseDown = (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
const currentImage = getCurrentImage();
|
||
if (!currentImage) return;
|
||
|
||
// 根据当前模式选择对应的容器和小地图
|
||
const container = isFullscreen.value
|
||
? { clientWidth: window.innerWidth, clientHeight: window.innerHeight }
|
||
: containerRef.value;
|
||
const minimap = isFullscreen.value
|
||
? fullscreenMinimapRef.value
|
||
: minimapRef.value;
|
||
|
||
if (!container || !minimap) return;
|
||
|
||
const rect = minimap.getBoundingClientRect();
|
||
|
||
// 点击位置在小地图坐标系中
|
||
const clickX = e.clientX - rect.left;
|
||
const clickY = e.clientY - rect.top;
|
||
|
||
// 计算小地图中图片的显示尺寸和偏移
|
||
const imgAspectRatio = currentImage.width / currentImage.height;
|
||
const minimapAspectRatio = rect.width / rect.height;
|
||
|
||
let minimapImgWidth, minimapImgHeight;
|
||
if (imgAspectRatio > minimapAspectRatio) {
|
||
minimapImgWidth = rect.width;
|
||
minimapImgHeight = rect.width / imgAspectRatio;
|
||
} else {
|
||
minimapImgHeight = rect.height;
|
||
minimapImgWidth = rect.height * imgAspectRatio;
|
||
}
|
||
|
||
const minimapImgOffsetX = (rect.width - minimapImgWidth) / 2;
|
||
const minimapImgOffsetY = (rect.height - minimapImgHeight) / 2;
|
||
|
||
// 计算图片在容器中的基础显示尺寸
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
const containerAspectRatio = containerWidth / containerHeight;
|
||
|
||
// 使用 contain 模式计算基础尺寸
|
||
let baseWidth, baseHeight;
|
||
if (imgAspectRatio > containerAspectRatio) {
|
||
// 图片更宽,以宽度为准(contain)
|
||
baseWidth = containerWidth;
|
||
baseHeight = containerWidth / imgAspectRatio;
|
||
} else {
|
||
// 图片更高,以高度为准(contain)
|
||
baseHeight = containerHeight;
|
||
baseWidth = containerHeight * imgAspectRatio;
|
||
}
|
||
|
||
// 点击位置相对于小地图图片左上角的坐标
|
||
const clickRelativeX = clickX - minimapImgOffsetX;
|
||
const clickRelativeY = clickY - minimapImgOffsetY;
|
||
|
||
// 转换为缩放后图片上的位置
|
||
const minimapToScaledRatio = (baseWidth * scale.value) / minimapImgWidth;
|
||
const scaledImageX = clickRelativeX * minimapToScaledRatio;
|
||
const scaledImageY = clickRelativeY * minimapToScaledRatio;
|
||
|
||
// 转换为相对于图片中心的坐标
|
||
const imageCenterX = scaledImageX - (baseWidth * scale.value) / 2;
|
||
const imageCenterY = scaledImageY - (baseHeight * scale.value) / 2;
|
||
|
||
// 设置新的translate值,使点击位置成为容器中心
|
||
translateX.value = -imageCenterX / scale.value;
|
||
translateY.value = -imageCenterY / scale.value;
|
||
|
||
render();
|
||
};
|
||
|
||
// 鼠标滚轮缩放
|
||
const handleWheel = (e) => {
|
||
const zoomFactor = e.deltaY > 0 ? 1 / props.zoomStep : props.zoomStep;
|
||
const newScale = Math.min(
|
||
Math.max(scale.value * zoomFactor, props.minZoom),
|
||
props.maxZoom,
|
||
);
|
||
scale.value = newScale;
|
||
|
||
render();
|
||
triggerImageUpdate();
|
||
};
|
||
|
||
// 鼠标按下
|
||
const handleMouseDown = (e) => {
|
||
isDragging.value = true;
|
||
dragStartX.value = e.clientX;
|
||
dragStartY.value = e.clientY;
|
||
initialTranslateX.value = translateX.value;
|
||
initialTranslateY.value = translateY.value;
|
||
|
||
e.preventDefault();
|
||
|
||
document.addEventListener("mousemove", handleMouseMove);
|
||
document.addEventListener("mouseup", handleMouseUp);
|
||
document.body.style.userSelect = "none";
|
||
};
|
||
|
||
// 鼠标移动
|
||
const handleMouseMove = (e) => {
|
||
if (!isDragging.value) return;
|
||
|
||
e.preventDefault();
|
||
|
||
const deltaX = e.clientX - dragStartX.value;
|
||
const deltaY = e.clientY - dragStartY.value;
|
||
|
||
translateX.value = initialTranslateX.value + deltaX / scale.value;
|
||
translateY.value = initialTranslateY.value + deltaY / scale.value;
|
||
|
||
render();
|
||
};
|
||
|
||
// 鼠标释放
|
||
const handleMouseUp = () => {
|
||
isDragging.value = false;
|
||
|
||
document.removeEventListener("mousemove", handleMouseMove);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
document.body.style.userSelect = "";
|
||
|
||
// 拖拽结束后检查是否需要更新图片
|
||
triggerImageUpdate();
|
||
};
|
||
|
||
// 触摸相关状态
|
||
const touchState = ref({
|
||
active: false,
|
||
touches: [],
|
||
initialDistance: 0,
|
||
initialScale: 1,
|
||
lastTouches: [],
|
||
});
|
||
|
||
// 触摸开始
|
||
const handleTouchStart = (e) => {
|
||
// 检查触摸目标是否是控件元素
|
||
const target = e.target;
|
||
if (
|
||
target.tagName === "BUTTON" ||
|
||
target.closest(".zoom-controls") ||
|
||
target.closest(".minimap")
|
||
) {
|
||
// 如果是控件,让控件自己处理触摸事件
|
||
return;
|
||
}
|
||
|
||
if (e.touches.length === 1) {
|
||
// 单指:准备拖拽
|
||
touchState.value.active = true;
|
||
touchState.value.touches = [e.touches[0]];
|
||
touchState.value.lastTouches = [e.touches[0]];
|
||
|
||
isDragging.value = true;
|
||
dragStartX.value = e.touches[0].clientX;
|
||
dragStartY.value = e.touches[0].clientY;
|
||
initialTranslateX.value = translateX.value;
|
||
initialTranslateY.value = translateY.value;
|
||
} else if (e.touches.length === 2) {
|
||
// 双指:准备缩放
|
||
touchState.value.active = true;
|
||
touchState.value.touches = [e.touches[0], e.touches[1]];
|
||
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
touchState.value.initialDistance = Math.sqrt(dx * dx + dy * dy);
|
||
touchState.value.initialScale = scale.value;
|
||
|
||
isDragging.value = false;
|
||
}
|
||
|
||
// 只在事件可取消且在非控件区域时阻止默认行为
|
||
if (e.cancelable) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
// 触摸移动
|
||
const handleTouchMove = (e) => {
|
||
if (!touchState.value.active) return;
|
||
|
||
// 检查触摸目标是否是控件元素
|
||
const target = e.target;
|
||
if (
|
||
target.tagName === "BUTTON" ||
|
||
target.closest(".zoom-controls") ||
|
||
target.closest(".minimap")
|
||
) {
|
||
// 如果是控件,不处理图片操作
|
||
return;
|
||
}
|
||
|
||
if (e.touches.length === 1 && touchState.value.touches.length === 1) {
|
||
// 单指拖拽
|
||
const deltaX = e.touches[0].clientX - dragStartX.value;
|
||
const deltaY = e.touches[0].clientY - dragStartY.value;
|
||
|
||
translateX.value = initialTranslateX.value + deltaX / scale.value;
|
||
translateY.value = initialTranslateY.value + deltaY / scale.value;
|
||
|
||
render();
|
||
} else if (e.touches.length === 2) {
|
||
// 双指缩放
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
const currentDistance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
if (touchState.value.initialDistance > 0) {
|
||
const scaleFactor = currentDistance / touchState.value.initialDistance;
|
||
const newScale = Math.min(
|
||
Math.max(touchState.value.initialScale * scaleFactor, props.minZoom),
|
||
props.maxZoom,
|
||
);
|
||
scale.value = newScale;
|
||
render();
|
||
}
|
||
}
|
||
|
||
// 只在事件可取消且在非控件区域时阻止默认行为
|
||
if (e.cancelable) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
// 触摸结束
|
||
const handleTouchEnd = (e) => {
|
||
// 检查触摸目标是否是控件元素
|
||
const target = e.target;
|
||
if (
|
||
target.tagName === "BUTTON" ||
|
||
target.closest(".zoom-controls") ||
|
||
target.closest(".minimap")
|
||
) {
|
||
// 如果是控件,不处理图片操作
|
||
return;
|
||
}
|
||
|
||
if (e.touches.length === 0) {
|
||
touchState.value.active = false;
|
||
touchState.value.touches = [];
|
||
isDragging.value = false;
|
||
triggerImageUpdate();
|
||
} else if (e.touches.length === 1 && touchState.value.touches.length === 2) {
|
||
// 从双指变为单指
|
||
touchState.value.touches = [e.touches[0]];
|
||
touchState.value.initialDistance = 0;
|
||
}
|
||
|
||
// 只在事件可取消且在非控件区域时阻止默认行为
|
||
if (e.cancelable) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
// 放大
|
||
const zoomIn = () => {
|
||
scale.value = Math.min(scale.value * props.zoomStep, props.maxZoom);
|
||
render();
|
||
triggerImageUpdate();
|
||
};
|
||
|
||
// 缩小
|
||
const zoomOut = () => {
|
||
scale.value = Math.max(scale.value / props.zoomStep, props.minZoom);
|
||
render();
|
||
triggerImageUpdate();
|
||
};
|
||
|
||
// 重置
|
||
const resetZoom = () => {
|
||
scale.value = 1;
|
||
translateX.value = 0;
|
||
translateY.value = 0;
|
||
render();
|
||
triggerImageUpdate();
|
||
};
|
||
|
||
// 全屏切换
|
||
const toggleFullscreen = () => {
|
||
isFullscreen.value = !isFullscreen.value;
|
||
|
||
nextTick(() => {
|
||
if (isFullscreen.value) {
|
||
// 进入全屏:初始化全屏 canvas
|
||
if (fullscreenCanvasRef.value) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
fullscreenCanvasRef.value.width = window.innerWidth * dpr;
|
||
fullscreenCanvasRef.value.height = window.innerHeight * dpr;
|
||
|
||
// 每次都重新初始化 context(因为组件可能被销毁重建)
|
||
fullscreenCtx = fullscreenCanvasRef.value.getContext("2d");
|
||
|
||
if (fullscreenCtx) {
|
||
fullscreenCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
fullscreenCtx.scale(dpr, dpr);
|
||
}
|
||
}
|
||
|
||
// 初始化全屏小地图 canvas 和 context
|
||
if (fullscreenMinimapCanvasRef.value && fullscreenMinimapRef.value) {
|
||
fullscreenMinimapCtx =
|
||
fullscreenMinimapCanvasRef.value.getContext("2d");
|
||
initMinimapCanvas(
|
||
fullscreenMinimapCanvasRef.value,
|
||
fullscreenMinimapRef.value,
|
||
fullscreenMinimapCtx,
|
||
);
|
||
}
|
||
|
||
document.body.style.overflow = "hidden";
|
||
} else {
|
||
// 退出全屏:恢复正常模式
|
||
document.body.style.overflow = "";
|
||
// 清理全屏 context
|
||
fullscreenCtx = null;
|
||
fullscreenMinimapCtx = null;
|
||
|
||
// 确保普通模式 canvas 尺寸正确
|
||
nextTick(() => {
|
||
if (canvasRef.value && containerRef.value) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
ctx = canvasRef.value.getContext("2d");
|
||
if (ctx) {
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(dpr, dpr);
|
||
}
|
||
// 确保尺寸正确并重新渲染
|
||
resizeCanvas();
|
||
render();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 重新渲染
|
||
render();
|
||
});
|
||
};
|
||
|
||
// 监听src变化
|
||
watch(
|
||
() => props.src,
|
||
() => {
|
||
loadImage();
|
||
},
|
||
);
|
||
|
||
// 监听窗口大小变化
|
||
const handleResize = () => {
|
||
resizeCanvas();
|
||
};
|
||
|
||
// 组件挂载
|
||
onMounted(() => {
|
||
ctx = canvasRef.value?.getContext("2d");
|
||
minimapCtx = minimapCanvasRef.value?.getContext("2d");
|
||
loadImage();
|
||
window.addEventListener("resize", handleResize);
|
||
|
||
// 初始化小地图 canvas
|
||
nextTick(() => {
|
||
initMinimapCanvas(minimapCanvasRef.value, minimapRef.value, minimapCtx);
|
||
renderMinimap();
|
||
});
|
||
});
|
||
|
||
// 组件卸载
|
||
onUnmounted(() => {
|
||
document.removeEventListener("mousemove", handleMouseMove);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
document.body.style.userSelect = "";
|
||
window.removeEventListener("resize", handleResize);
|
||
|
||
// 退出全屏(如果处于全屏状态)
|
||
if (isFullscreen.value) {
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
|
||
if (minimapAnimationFrameId) {
|
||
cancelAnimationFrame(minimapAnimationFrameId);
|
||
}
|
||
|
||
if (debounceTimer.value) {
|
||
clearTimeout(debounceTimer.value);
|
||
}
|
||
|
||
// 清理图片资源
|
||
imageCache.value.clear();
|
||
originalImage = null;
|
||
|
||
// 清理 context
|
||
ctx = null;
|
||
fullscreenCtx = null;
|
||
minimapCtx = null;
|
||
fullscreenMinimapCtx = null;
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.zoom-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 400px;
|
||
overflow: hidden;
|
||
background: var(--vp-c-bg-soft);
|
||
border: 1px solid var(--vp-c-border);
|
||
border-radius: 4px;
|
||
user-select: none;
|
||
cursor: grab;
|
||
box-sizing: border-box;
|
||
touch-action: none;
|
||
}
|
||
|
||
.fullscreen-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: var(--vp-c-bg-soft);
|
||
overflow: hidden;
|
||
user-select: none;
|
||
cursor: grab;
|
||
z-index: 9999;
|
||
touch-action: none;
|
||
}
|
||
|
||
.zoom-canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
image-rendering: -webkit-optimize-contrast;
|
||
image-rendering: crisp-edges;
|
||
image-rendering: pixelated;
|
||
}
|
||
|
||
.loading-indicator {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--vp-c-bg-elv);
|
||
padding: 12px 24px;
|
||
border-radius: 4px;
|
||
box-shadow: var(--vp-shadow-2);
|
||
font-size: 14px;
|
||
color: var(--vp-c-text-2);
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
bottom: 12px;
|
||
right: 12px;
|
||
background: rgba(var(--vp-c-bg-rgb, 255, 255, 255), 0.85);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--vp-c-divider);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
padding: 4px;
|
||
gap: 2px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||
opacity: 0.4;
|
||
transform: translateY(0);
|
||
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
z-index: 20;
|
||
}
|
||
|
||
.zoom-container:hover .zoom-controls,
|
||
.fullscreen-overlay:hover .zoom-controls {
|
||
opacity: 1;
|
||
}
|
||
|
||
.zoom-tool-group {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.zoom-tool-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--vp-c-text-2);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
padding: 0;
|
||
}
|
||
|
||
.zoom-tool-btn:hover {
|
||
background: var(--vp-c-brand-soft);
|
||
color: var(--vp-c-brand-1);
|
||
}
|
||
|
||
.zoom-divider-v {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--vp-c-divider);
|
||
align-self: center;
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.minimap {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
background: var(--vp-c-bg-elv);
|
||
border: 1px solid var(--vp-c-border);
|
||
border-radius: 4px;
|
||
box-shadow: var(--vp-shadow-2);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
z-index: 10;
|
||
}
|
||
|
||
.minimap-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.minimap-viewport {
|
||
position: absolute;
|
||
border: 2px solid var(--vp-c-brand-1);
|
||
background: var(--vp-c-brand-soft);
|
||
pointer-events: none;
|
||
transition: all 0.1s ease-out;
|
||
}
|
||
</style>
|