平铺/非平铺图像对比功能开发

112 阅读3分钟

需求描述

平铺对比图片时,图片一左一右;非平铺模式支持手柄拖拽对比。

实现思路

技术选型

  • 框架:Vue 3 提供响应式和组件化支持。
  • UI 库:Element Plus 用于弹窗和表单控件。
  • 对比工具:image-compare-viewer 是一个轻量级库,适合实现图片对比拖拽效果。

组件设计

核心组件为 ImageCompare.vue,负责渲染图片和处理模式切换。设计包括:

  • 平铺模式:使用 flex 布局并排显示两张图片,应用缩放效果。
  • 非平铺模式:利用 image-compare-viewer 初始化对比器,动态设置容器尺寸。
  • 动态适配:根据图片的 naturalWidth 和 naturalHeight 计算样式,确保比例正确。

实现步骤

  1. 初始化:在组件挂载时检查图片加载状态。
  2. 模式切换:通过 watch 监听 mode 属性,动态切换渲染逻辑。
  3. 缩放处理:监听 scale 属性,调整图片和容器大小。
  4. 对比逻辑:在图片加载完成后调用 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 的比例: