需求描述
平铺对比图片时,图片一左一右;非平铺模式支持手柄拖拽对比。
实现思路
技术选型
- 框架:Vue 3 提供响应式和组件化支持。
- UI 库:Element Plus 用于弹窗和表单控件。
- 对比工具:image-compare-viewer 是一个轻量级库,适合实现图片对比拖拽效果。
组件设计
核心组件为 ImageCompare.vue,负责渲染图片和处理模式切换。设计包括:
- 平铺模式:使用 flex 布局并排显示两张图片,应用缩放效果。
- 非平铺模式:利用 image-compare-viewer 初始化对比器,动态设置容器尺寸。
- 动态适配:根据图片的 naturalWidth 和 naturalHeight 计算样式,确保比例正确。
实现步骤
- 初始化:在组件挂载时检查图片加载状态。
- 模式切换:通过 watch 监听 mode 属性,动态切换渲染逻辑。
- 缩放处理:监听 scale 属性,调整图片和容器大小。
- 对比逻辑:在图片加载完成后调用 initCompare 初始化 image-compare-viewer。
代码
App.vue
<template>
<div class="app">
<el-button type="primary" @click="openModal">Open Image Compare</el-button>
<el-dialog
v-model="isModalOpen"
title="Image Comparison"
width="90%"
:before-close="handleClose"
destroy-on-close
>
<ImageCompare
:left-img="leftImg"
:right-img="rightImg"
v-model:mode="mode"
:scale="scale"
@loaded="onImagesLoaded"
@error="handleError"
/>
<div class="controls">
<el-form inline>
<el-form-item label="Mode">
<el-select v-model="mode" @change="handleModeChange">
<el-option label="Tile" value="tile" />
<el-option label="Compare" value="compare" />
</el-select>
</el-form-item>
<el-form-item label="Scale">
<el-slider
v-model="scale"
:min="10"
:max="200"
:step="1"
show-input
style="width: 200px"
/>
</el-form-item>
</el-form>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import ImageCompare from './components/ImageCompare.vue';
const isModalOpen = ref(false);
const leftImg = ref('https://images.unsplash.com/photo-1600585154340-be6161a56a0c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&h=400');
const rightImg = ref('https://images.unsplash.com/photo-1517336714731-489689fd1ca8?ixlib=rb-4.0.3&auto=format&fit=crop&w=540&h=960');
const mode = ref('compare');
const scale = ref(100);
const openModal = () => {
isModalOpen.value = true;
};
const handleClose = (done) => {
ElMessageBox.confirm('Are you sure to close?', 'Warning')
.then(() => done())
.catch(() => {});
};
const handleModeChange = (value) => {
mode.value = value;
};
const onImagesLoaded = () => {
console.log('Images loaded successfully');
};
const handleError = (error) => {
ElMessage.error(`Error: ${error.message}`);
};
</script>
<style scoped>
.app {
padding: 20px;
text-align: center;
}
.controls {
margin-top: 20px;
display: flex;
justify-content: center;
}
</style>
代码
./components/ImageCompare.vue组件
<template>
<div class="image-compare-container">
<!-- 平铺模式 -->
<div v-if="isTile" class="tile-mode">
<div class="img-tile">
<div class="img-wrapper">
<img
:src="leftImg"
alt="Before Image"
ref="leftImgRef"
:style="{ transform: `scale(${scale / 100})` }"
@load="handleImageLoad"
/>
</div>
</div>
<div class="img-tile">
<div class="img-wrapper">
<img
:src="rightImg"
alt="After Image"
ref="rightImgRef"
:style="{ transform: `scale(${scale / 100})` }"
@load="handleImageLoad"
/>
</div>
</div>
</div>
<!-- 对比模式 -->
<div v-else ref="compareContainer" class="compare-mode" :style="compareModeStyle">
<div class="compare-wrapper">
<img
:src="leftImg"
class="compare-left"
alt="Before"
ref="leftImgRef"
@load="handleImageLoad"
/>
<img
:src="rightImg"
class="compare-right"
alt="After"
ref="rightImgRef"
@load="handleImageLoad"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onBeforeUnmount, computed } from 'vue';
import ImageCompare from 'image-compare-viewer';
import 'image-compare-viewer/dist/image-compare-viewer.min.css';
const props = defineProps({
leftImg: { type: String, required: true },
rightImg: { type: String, required: true },
mode: { type: String, default: 'compare', validator: (value) => ['compare', 'tile'].includes(value) },
scale: { type: Number, default: 100, validator: (value) => value > 0 && value <= 200 }
});
const emit = defineEmits(['update:mode', 'loaded', 'error']);
const isTile = ref(props.mode === 'tile');
const compareContainer = ref(null);
const leftImgRef = ref(null);
const rightImgRef = ref(null);
const imagesLoaded = ref(false);
const viewer = ref(null);
// 计算对比模式的样式
const compareModeStyle = computed(() => {
if (isTile.value || !imagesLoaded.value) return {};
const leftWidth = leftImgRef.value?.naturalWidth || 1;
const leftHeight = leftImgRef.value?.naturalHeight || 1;
const rightWidth = rightImgRef.value?.naturalWidth || 1;
const rightHeight = rightImgRef.value?.naturalHeight || 1;
const maxWidth = Math.max(leftWidth, rightWidth);
const maxHeight = Math.max(leftHeight, rightHeight);
const aspectRatio = maxWidth / maxHeight;
return {
width: `${maxWidth * (props.scale / 100)}px`,
height: `${maxHeight * (props.scale / 100)}px`,
maxWidth: '100%',
maxHeight: '80vh',
overflow: 'hidden',
position: 'relative'
};
});
// 初始化图片对比器
const initCompare = () => {
if (!compareContainer.value || !imagesLoaded.value || !leftImgRef.value || !rightImgRef.value) return;
destroyCompare();
try {
viewer.value = new ImageCompare(compareContainer.value, {
orientation: 'horizontal',
controlColor: '#FFFFFF',
controlShadow: true,
addCircle: true,
showLabels: false,
smoothing: true,
verticalMode: false,
fluidMode: true,
hoverStart: false,
labelOptions: { before: '', after: '', onHover: false }
}).mount();
console.log('ImageCompare initialized successfully');
} catch (error) {
console.error('图片对比初始化失败:', error);
emit('error', error);
}
};
// 销毁对比器
const destroyCompare = () => {
if (viewer.value) {
try {
viewer.value.destroy();
} catch (e) {
console.warn('销毁对比器时出错:', e);
}
viewer.value = null;
}
};
// 图片加载处理
const handleImageLoad = () => {
if (leftImgRef.value?.complete && rightImgRef.value?.complete) {
imagesLoaded.value = true;
emit('loaded');
if (!isTile.value) {
nextTick(() => {
const container = compareContainer.value;
if (container) {
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
leftImgRef.value.style.width = `${containerWidth}px`;
leftImgRef.value.style.height = `${containerHeight}px`;
rightImgRef.value.style.width = `${containerWidth}px`;
rightImgRef.value.style.height = `${containerHeight}px`;
initCompare();
}
});
}
}
};
// 监听模式变化
watch(
() => props.mode,
(newMode) => {
isTile.value = newMode === 'tile';
emit('update:mode', newMode); // 同步 v-model
if (imagesLoaded.value) {
nextTick(() => {
if (newMode === 'compare') {
initCompare();
} else {
destroyCompare();
}
});
}
}
);
// 监听缩放比例变化
watch(
() => props.scale,
() => {
if (imagesLoaded.value && !isTile.value) {
nextTick(initCompare);
}
}
);
// 组件挂载时检查图片是否已加载
onMounted(() => {
if (leftImgRef.value?.complete && rightImgRef.value?.complete) {
handleImageLoad();
}
});
// 组件卸载前清理
onBeforeUnmount(() => {
destroyCompare();
});
</script>
<style scoped>
.image-compare-container {
display: flex;
justify-content: center;
width: 100%;
margin: 0 auto;
}
.tile-mode {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 1200px;
}
.img-tile {
flex: 1;
min-width: 0;
max-width: 50%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
background-color: #f5f5f5;
}
.img-wrapper {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
overflow: hidden;
}
.img-wrapper img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.3s ease;
}
.compare-mode {
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
background-color: #f5f5f5;
margin: 0 auto;
}
.compare-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.compare-right {
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover; /* 使用 cover 确保填充容器,可能裁剪部分内容 */
object-position: center;
}
</style>
./components/Modal.vue组件代码
<!-- components/Modal.vue -->
<template>
<div class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<button class="close-button" @click="closeModal">×</button>
<slot></slot>
</div>
</div>
</template>
<script setup>
import { defineEmits } from 'vue';
const emit = defineEmits(['close']);
const closeModal = () => {
emit('close');
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 90%;
max-height: 90vh;
overflow: auto;
position: relative;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
</style>
测试与验证
为确保组件在不同比例下表现良好,提供了以下测试图片地址,涵盖大于 1、小于 1 和等于 1 的比例:
-
1:1 (正方形)
- URL: images.unsplash.com/photo-15067…
- 描述: 绿色植物特写
-
16:9 (宽屏)
- URL: images.unsplash.com/photo-15018…
- 描述: 湖泊风景
-
4:3 (传统比例)
- URL: images.unsplash.com/photo-15173…
- 描述: 城市街景
-
3:1 (超宽)
- URL: images.unsplash.com/photo-16005…
- 描述: 海岸线全景
-
3:4 (高度比宽度多 1/3)
- URL: images.unsplash.com/photo-16005…
- 描述: 垂直的森林风景
-
9:16 (高度接近两倍宽度)
- URL: images.unsplash.com/photo-15173…
- 描述: 垂直的瀑布特写
-
2:3 (高度比宽度多 1/2)
- URL: images.pexels.com/photos/1109…
- 描述: 垂直的花卉摄影
-
1:2 (高度两倍宽度)
- URL: images.unsplash.com/photo-15197…
- 描述: 垂直的建筑剪影