基于 vue3 hook 的图片查看器

570 阅读6分钟

基于 vue3 hook 的图片查看

最近在为内部平台实现一个抠图功能页,以 创客贴在线抠图 为参考效果,支持手动/自动抠图,擦除/修补,背景,裁剪等功能。

image.png

图片查看是其中一个功能,需要支持图片放大缩小,拖动位置,当然不限于图片作为内容,画布(canvas 元素)等不受影响,尝试学习如何通过 hook 方式独立出内容查看的实现;

本文章示例项目:v3-viewable-demo

v3-viewable npm:v3-viewable

image.png

准备

VueUse

image.png

VueUse 作为优秀实践,官网一些章节值得花几分钟一看:

Best Practice(官网) 最佳实践(中文)

Components(官网) 组件(中文)

Guidelines(官网) 使用准则(中文)

useDraggable 为参考,见github 源码

开始

首先实现一个简单的页面结构:

// imagePreviewer.vue
<template>
  <div class="h-100vh relative">
    <img
      ref="viewableRef"
      class="absolute"
      src="@/assets/images/post-bg-tree.jpg"
    />
    <div class="control-bar"></div>
  </div>
  </div>
</template>

一个 relative 的容器(container),和一张图片作为查看内容(viewableRef) 另外一个控制栏占位

在编写功能之前分析内容查看功能涉及的对象和操作,主要操作包含:

  • 拖动位置:监听鼠标事件修改内容 dom 的位置(position)
  • 缩放:监听鼠标滚轮事件修改内容 dom 的尺寸(size)

且事件绑定和操作状态的主要对象是内容元素,因此期望:

<script setup>
import { useViewable } from "./hooks/useViewable/index.js";

const viewableRef = ref(null);
const { style } = useViewable(viewableRef, {});
</script>


<template>
  <div class="h-100vh relative">
    <img
      ref="viewableRef"
      :style="style"
      class="absolute"
      src="@/assets/images/post-bg-tree.jpg"
    />
    <div class="control-bar"></div>
  </div>
</template>

useViewable

初始化

实现 useViewable,第一步应该是定义相关状态,包括位置相关状态和缩放相关状态

const useViewable = (target, options) => {
  // 新增:位置相关状态
  const position = ref({ x: 0, y: 0 });
  // 新增:缩放相关状态
  const scale = ref(1);
  const width = ref(0);
  const height = ref(0);
};

其次分别各状态初始化:

初始化宽高状态

const useViewable = (target, options) => {
  const position = ref({ x: 0, y: 0 });
  const scale = ref(1);
  const width = ref();
  const height = ref();

  const initial = () => {
    // 新增:宽高尺寸状态
    width.value = contentElement.clientWidth;
    height.value = contentElement.clientHeight;
  };

  onMounted(() => {
    contentElement = target.value;
    if (contentElement.clientWidth && contentElement.clientHeight) initial();
    // 新增:含图片内容的初始化
    contentElement.addEventListener("load", initial, true);
  });
};

期待的初始状态下,图片作为查看内容(contentElement),默认应当缩放到合适比例,并位置相对于容器居中;

于是:

初始化缩放比

我们保证图片内容在容器内,类似 css object-fit: contain 效果

编写一个辅助方法 calculateScale

// helper.js

// 根据内容宽高、容器宽高,计算出初始缩放比
export const calculateScale = (contentSize, containerSize) => {
  const { width: contentWidth, height: contentHeight } = contentSize;
  const { width: containerWidth, height: containerHeight } = containerSize;
  const initialScale = Math.min(
    containerWidth / contentWidth,
    containerHeight / contentHeight
  );
  // 向下保留2位小数
  const scale = Math.floor(initialScale * 100) / 100;
  return scale;
};

调用上面方法,传入内容/容器尺寸作为参数,计算结果作为初始缩放比(scale)

import { calculateScale } from "./helper.js";

const useViewable = (target, options) => {
  let { containerElement } = options;
  let contentElement;
  const position = ref({ x: 0, y: 0 });
  const scale = ref(1);
  const width = ref();
  const height = ref();

  const initial = () => {
    width.value = contentElement.clientWidth;
    height.value = contentElement.clientHeight;
    const contentSize = {
      width: contentElement.clientWidth,
      height: contentElement.clientHeight,
    };
    const containerSize = {
      width: containerElement.clientWidth,
      height: containerElement.clientHeight,
    };
    // 新增:初始化缩放比
    scale.value = calculateScale(contentSize, containerSize);
  };

  onMounted(() => {
    contentElement = target.value;
    // 容器(containerElement)可选参数指定,否则为内容元素的父节点;
    containerElement = containerElement || contentElement.parentNode;
    if (target.value.clientWidth && target.value.clientHeight) initial();
  });
};

scale 内容缩放比决定图片宽高,补充根据 scale 计算尺寸的逻辑

const useViewable = (target, options) => {
  // ...
  // 新增:内容原尺寸
  const originSize = { width: 0, height: 0 };

  const initial = () => {
    width.value = contentElement.clientWidth;
    height.value = contentElement.clientHeight;
    originSize.width = width.value;
    originSize.height = height.value;
    const containerSize = {
      width: containerElement.clientWidth,
      height: containerElement.clientHeight,
    };
    scale.value = calculateScale(originSize, containerSize);
  };

  // 新增:监听 scale 计算内容尺寸
  watch(scale, (newValue) => {
    width.value = Math.floor(newValue * originSize.width);
    height.value = Math.floor(newValue * originSize.height);
  });
};

初始化位置

实现默认图片内容位置居中,编写一个辅助方法 calculateCenterPosition

export const calculateCenterPosition = (contentSize, containerSize) => {
  const position = {
    x: Math.round((containerSize.width - contentSize.width) / 2),
    y: Math.round((containerSize.height - contentSize.height) / 2),
  };
  return position;
};

在初始化缩放比后添加位置居中处理

const initial = () => {
  width.value = contentElement.clientWidth;
  height.value = contentElement.clientHeight;
  originSize.width = width.value;
  originSize.height = height.value;
  const contentSize = {
    width: width.value,
    height: height.value,
  };
  const containerSize = {
    width: containerElement.clientWidth,
    height: containerElement.clientHeight,
  };
  scale.value = calculateScale(originSize, containerSize);
  // 新增:设置初始位置居中
  position.value = calculateCenterPosition(contentSize, containerSize);
};

应用 style

useViewable 组装返回 style 并暴露一些状态:

const useViewable = (target, options) => {
  return {
    width,
    height,
    position,
    scale,
    style: computed(
      () =>
        `width: ${width.value}px;
        height: ${height.value}px;
        left: ${position.value.x}px;
        top: ${position.value.y}px;`
    ),
  };
};

监听交互事件

有了基本状态,分别绑定 wheel 滚轮缩放事件以及 mouse 鼠标拖动事件实现操作

采用 VueUse 提供的 useDraggableuseEventListener 实现:

const useViewable = (target, options) => {
  let {
    containerElement,
    scaleStep = 0.05, // 缩放步长
  } = options;

  ......

  const move = ({ x, y }) => (position.value = { x, y });

  const wheel = (event) => {
    const deltaY = event.deltaY;
    if (deltaY === 0) return;
    const diff = deltaY > 0 ? -scaleStep : scaleStep;
    const newScale = scale.value + diff;
    scale.value = +newScale.toFixed(2);
    event.preventDefault();
  };

  onMounted(() => {
    contentElement = target.value;
    containerElement = containerElement || contentElement.parentNode;
    if (contentElement.clientWidth && contentElement.clientHeight) initial();
    useEventListener(contentElement, "load", initial, true);
    useDraggable(contentElement, { onMove: move });
    useEventListener(containerElement, "wheel", wheel);
  });

  return {
    ......
  }
}

优化项

支持自定义初始尺寸占比

默认内容占满容器(object-contain)的效果之外,支持定义初始内容尺寸占容器比

改造 calculateScale 新增参数 percentage

export const calculateScale = (contentSize, containerSize, percentage) => {
  const { width: contentWidth, height: contentHeight } = contentSize;
  const { width: containerWidth, height: containerHeight } = containerSize;
  const initialScale = Math.min(
    (containerWidth * percentage) / contentWidth,
    (containerHeight * percentage) / contentHeight
  );
  const scale = Math.floor(initialScale * 100) / 100;
  return scale;
};

useViewable 新增参数 initialSizePercentage

const useViewable = (target, options) => {
  let {
    containerElement,
    initialSizePercentage = 1, // 默认 1 即宽高之一占满容器
    scaleStep = 0.02,
  } = options;

  ......

  const initial = () => {

    ......
    scale.value = calculateScale(
      originSize,
      containerSize,
      initialSizePercentage
    );
  };

  ......
}

通过指定 initialSizePercentage,实现效果:

const { scale, style } = useViewable(viewableRef, {
  initialSizePercentage: 0.8,
});

image.png

中心缩放

我们目前的缩放代码只是修改宽高,位置不受影响,这看起来像基于左上角缩放

watch(scale, (newValue) => {
  width.value = Math.round(newValue * originSize.width);
  height.value = Math.round(newValue * originSize.height);
});

调整为中心缩放,只需要每次宽高缩放同时,x,y 分别加上/减去尺寸差值的 1/2

watch(scale, (newValue) => {
  const oldWidth = width.value;
  const oldHeight = height.value;
  width.value = Math.round(newValue * originSize.width);
  height.value = Math.round(newValue * originSize.height);
  // 新增:计算并更新位置
  position.value.x -= Math.round((width.value - oldWidth) / 2);
  position.value.y -= Math.round((height.value - oldHeight) / 2);
});

感兴趣还可以再优化,额外考虑鼠标点位置,根据鼠标位置决定缩放中心位置,而不是简单内容中心缩放

支持监听缩放与拖动操作

使用 useViewable 希望监听一些操作,通过传入方法在对应时机调用

const useViewable = (target, options) => {
  let {
    containerElement,
    initialSizePercentage = 1,
    scaleStep = 0.02,
    onDrag,
    onScale,
  } = options;

  ......


  const move = ({ x, y }) => {
    position.value = { x, y };
    onDrag?.({ x, y }); // 新增:调用onDrag
  };

  const wheel = (event) => {
    ......
    onScale?.(event); // 新增:调用onScale
  };

  ......

如实现缩放操作时,修改内容 DOM 鼠标 cursor 样式:

const viewableRef = ref(null);
let timeoutId;
const { scale, style } = useViewable(viewableRef, {
  initialSizePercentage: 0.8,
  onScale(event) {
    const cursorName = event.deltaY > 0 ? "cursor-zoom-out" : "cursor-zoom-in";
    viewableRef.value.classList.add(cursorName);
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      viewableRef.value.classList.remove("cursor-zoom-in");
      viewableRef.value.classList.remove("cursor-zoom-out");
    }, 300);
  },
});

controlBar

完善页面控制栏,支持缩放比显示,重置缩放比,还可以添加其他操作

<div class="control-bar">
  <button class="px-4px" @click="scale -= 0.05">-</button>
  <div class="px-4px w-50px">{{ `${parseInt(scale * 100)}%` }}</div>
  <button class="px-4px" @click="scale += 0.05">+</button>
  <div class="px-4px cursor-pointer" @click="handleToggleProportionMode">
    {{ proportionMode === "optimal" ? "&emsp;1:1&emsp;" : "最佳比例" }}
  </div>
</div>

实现一个 handleToggleProportionMode 方法,切换最佳比例和原比例

const proportionMode = ref("optimal"); // optimal:最佳比例 origin:原比例
const handleToggleProportionMode = () => {
  const mode = proportionMode.value === "optimal" ? "origin" : "optimal";
  if (mode === "optimal") {
    setScale(calcScaleBySizePercentage(0.8));
  }
  if (mode === "origin") {
    setScale(1);
  }
  proportionMode.value = mode;
};

这要求 useViewable 提供一个 setScale 方法,用于手动设置缩放比(且保证位置居中)

const useViewable = (target, options) => {
  ......
  const setScale = (value) => {
    scale.value = value;
    // 设置位置居中
    const contentSize = {
      width: width.value,
      height: height.value,
    };
    const containerSize = {
      width: containerElement.clientWidth,
      height: containerElement.clientHeight,
    };
    position.value = calculateCenterPosition(contentSize, containerSize);
  };
  .....
  retrun {
      ......
      setScale,
      calcScaleBySizePercentage
  }
};

提供一个 calcScaleBySizePercentage 方法,根据指定内容占容器比例计算缩放比

const calcScaleBySizePercentage = (percentage) => {
  const containerSize = {
    width: containerElement.clientWidth,
    height: containerElement.clientHeight,
  };
  const scale = calculateScale(originSize, containerSize, percentage);
  return scale;
};

Component Usage

考虑 useViewable 支持以组件(component)使用 定义一个 component.js:

import { useViewable } from "./index.js";

export const UseViewable = defineComponent({
  name: "UseViewable",
  props: {
    containerElement: {
      type: Object,
      default: null,
      validator(value) {
        return value instanceof Element;
      },
    },
    initialSizePercentage: { type: Number, default: 1 },
    scaleStep: { type: Number, default: 0.02 },
    onDrag: { type: Function, default: () => {} },
    onScale: { type: Function, default: () => {} },
  },
  setup(props, { slots, expose }) {
    const target = ref(null);
    const data = reactive(useViewable(target, { ...props }));
    expose(data);
    return () =>
      h(
        "div",
        { ref: target, style: data.style },
        slots.default ? slots.default(data) : []
      );
  },
});

使用:

<div class="h-100vh relative overflow-hidden">
  <UseViewable ref="viewableRef" class="absolute cursor-move max-w-none">
    <img src="@/assets/images/post-bg-tree.jpg" draggable="false" />
  </UseViewable>
</div>

通过 viewableRef 调用 viewableRef.value.scale = 1

npm

最后,将 useViewable 作为 npm 包(v3-viewable)发布,方便其他项目使用

npm i v3-viewable -S