基于 vue3 hook 的图片查看
最近在为内部平台实现一个抠图功能页,以 创客贴在线抠图 为参考效果,支持手动/自动抠图,擦除/修补,背景,裁剪等功能。
图片查看是其中一个功能,需要支持图片放大缩小,拖动位置,当然不限于图片作为内容,画布(canvas 元素)等不受影响,尝试学习如何通过 hook 方式独立出内容查看的实现;
本文章示例项目:v3-viewable-demo
v3-viewable
npm:v3-viewable
准备
VueUse
VueUse 作为优秀实践,官网一些章节值得花几分钟一看:
以 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
提供的 useDraggable
与 useEventListener
实现:
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,
});
中心缩放
我们目前的缩放代码只是修改宽高,位置不受影响,这看起来像基于左上角缩放
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" ? " 1:1 " : "最佳比例" }}
</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