在工作中经常遇到需要预览一张尺寸可能非常大的图片,初始化显示的时候,希望它自适应显示区域后,还可以缩放并可以在显示区域中拖拽。
在这里,手把手展示一下如何实现一个简单的组件,以实现上述的需求。
效果展示
先看看效果
可以直达👇
别忘了,可以带话,给我一个 Star 哟!
实现 Hook
在实现组件之前,可以先实现一个 hook,以包含核心逻辑,后面实现 组件 和 指令 的时候需要依赖它。
使用方式
<template>
<div ref="board" class="demo">
<div>Somethings</div>
</div>
</template>
// 页面
import { ref } from 'vue'
import { useZoomDrag } from 'vue3-zoom-drag'
const board = ref<HTMLElement>()
useZoomDrag({
board,
zoomMin: 0.1,
})
这里的 hook 就是 useZoomDrag
配置项
先设计一下,接下来要实现那些功能配置
export type useZoomDragOptions = {
/**
* 容器区域
*/
board: Ref<HTMLElement | undefined> | ComputedRef<HTMLElement | undefined>
/**
* 目标区域
*/
target?: Ref<HTMLElement | undefined> | ComputedRef<HTMLElement | undefined>
/**
* 目标变化事件
*/
onTargetChange?: (info: ZoomDragSize & { zoom: number }, methods: ZoomDragMethods) => void
/**
* 容器大小变化事件
*/
onBoardChange?: (info: ZoomDragSize, methods: ZoomDragMethods) => void
/**
* 初始化完成事件
*/
onReady?: () => void
/**
* 放大缩小速率(默认 0.1)
*/
zoomSpeed?: number
/**
* 最高放大倍速(默认 3)
*/
zoomMax?: number
/**
* 最低缩小倍速(默认 0.2)
*/
zoomMin?: number
/**
* 内边距
*/
padding?: [number, number, number, number]
}
- board - 作为可视区域的 HTML 节点
- target - 可以缩放、拖拽的目标 HTML 节点
zoomSpeed - 缩放速度可配置,即 dom 每次触发 wheel 事件时,增加/减少多少缩放比例,例如,0.1、0.3....
假如当前目标缩放比例为 1.2,缩放速度为 0.2,则 wheel 放大一次后,最新缩放比例就是 1.2 + 0.2 = 1.4;反之缩小一次,就是 1.2 - 0.2 = 1。
- zoomMax - 最大缩放比例可配置,例如,2倍、3倍...
- zoomMin - 最小缩放比例可配置,例如,0.5倍、0.2倍...
onReady - 初始化完成事件,默认会执行一次“自适应显示区域”操作(也就是后面会提到的 fitSize 方法)。
值得一提的是,如果目标本身并非 img 节点,但是内部却包含 img 节点,此时,需要在 img 节点的 onload 事件中手动执行一次 fitSize 方法。
原因就是目标节点(例如一个 div),它的大小取决于一个需要异步加载的节点(例如 img),初始化的时候执行的 fitSize,无法获得真实的大小尺寸。- onTargetChange - 目标变化事件,会返回关于目标的大小信息和缩放比例,并暴露一个 fitSize 方法。
- onBoardChange - 容器大小变化事件,会返回关于显示区域的大小信息(例如浏览器窗口大小发生了改变,导致显示区域发生了改变),并暴露一个 fitSize 方法。
PS: 关于大小信息的定义:
export interface ZoomDragSize {
width: number
height: number
left: number
top: number
}
- padding - 内边距,主要是为了预留一些空间,可以放置一些自定义的操作按钮。
假如设置为
padding: [0, 100, 0, 0]
代码实现
基础结构
先忽略具体细节,看看主要这个 hook 的代码结构:
import { ref, type Ref, reactive, watch } from 'vue'
// 类型定义
import type { useZoomDragOptions, ZoomDragMethods, ZoomDragSize } from '../types'
// 配置的默认值
const DefaultOptions: Partial<useZoomDragOptions> = {
zoomSpeed: 0.1,
zoomMax: 10,
zoomMin: 0.2,
padding: [0, 0, 0, 0],
}
// hook 主体
export default function useZoomDrag(opts: useZoomDragOptions): {
// hook 返回的内容
// 目标、显示区域的相关信息是 Ref 类型的
target: Ref<ZoomDragSize & { zoom: number }>
board: Ref<ZoomDragSize>
// 暴露一些方法,目前只有 fitSize(自适应显示区域)
methods: ZoomDragMethods
} {
// 传入配置的默认值合并
const options = { ...DefaultOptions, ...opts }
// 略
// 返回的内容(目标、显示区域的相关信息)
const targetInfoRef: Ref<ZoomDragSize & { zoom: number }> = ref({
width: 0,
height: 0,
left: 0,
top: 0,
zoom: 1,
})
const boardInfoRef: Ref<ZoomDragSize> = ref({
width: 0,
height: 0,
left: 0,
top: 0,
})
// 获得目标节点(如果没有通过配置传入,则获取显示区域的第一个子节点)
function getTarget() {
// 略
}
// 略
// 自适应大小(需要暴露的方法)
// animate 表示缩放时是否显示缩放动画
async function fitSize(animate = false) {
// 略
}
// 略
// 涉及到的 Dom 事件处理
const eventHandlers = {
// 缩放
zoom: (e: WheelEvent) => {
// 略
},
// 排除右键菜单的影响
contextmenu: (e: MouseEvent) => {
e.preventDefault()
},
// 拖拽相关逻辑
dragStart: (e: MouseEvent) => {
// 略
},
dragMove: (e: MouseEvent) => {
// 略
},
dragEnd: () => {
// 略
},
}
// 事件处理(绑定逻辑)
function eventHandle() {
// 略
}
// 略
// 容器区域必须样式(初始化可视区域的必要样式)
function boardStyle() {
// 略
}
// 目标区域必须样式(初始化目标的必要样式)
function targetStyle() {
// 略
}
// 初始化逻辑
watch(
() => [options.board.value, options.target?.value],
async () => {
const target = getTarget()
if (options.board.value && target) {
// 必须样式
boardStyle()
targetStyle()
// 事件控制
eventHandle()
// 默认执行一次 自适应显示区域
await fitSize()
// 初始化完成(事件返回)
options.onReady && options.onReady()
}
},
{
immediate: true,
}
)
// 返回的内容
return {
target: targetInfoRef,
board: boardInfoRef,
methods: { fitSize },
}
}
这里可以看出,整个初始化过程并不复杂,大概步骤为:
- 获取并检查 可视区域、目标 节点
- 赋予 可视区域、目标 节点必要的样式
- 绑定一些事件的处理
- 默认执行一次 自适应显示区域
- 通知 初始化完成
下面一步步实现逻辑细节
获取目标节点
function getTarget() {
if (options.target?.value === void 0) {
if (options.board.value !== void 0) {
return options.board.value.children[0] as HTMLElement
}
}
return options.target?.value
}
如果没有通过配置传入,则获取显示区域的第一个子节点。
必要的样式
可视区域
// 容器区域必须样式
function boardStyle() {
if (options.board.value) {
const boardComputedStyle = getComputedStyle(options.board.value)
options.board.value.style.overflow = 'hidden'
options.board.value.style.userSelect = 'none'
if (!['absolute', 'relative', 'fixed'].includes(boardComputedStyle.position)) {
options.board.value.style.position = 'relative'
}
}
}
- 隐藏超出区域的内容
- 不允许选择交互
position 必须是 absolute/relative/fixed 其中一个
这里通过 getComputedStyle 判断节点当前的 position,如已经满足上述条件,则无需处理;否则,给予 position 为 relative 。
// 目标区域必须样式
function targetStyle() {
const target = getTarget()
if (target) {
target.style.position = 'absolute'
target.style.transform = 'scale(1)'
target.style.transformOrigin = '0 0'
target.style.userSelect = 'none'
target.draggable = false
}
}
- position 设置为 absolute
- 初始化 transform 为 原始比例
- 设置 transform 基于自身的左上角
- 不允许选择交互
- 不允许原生拖拉拽交互
事件绑定
// 事件处理
function eventHandle() {
const target = getTarget()
if (options.board.value && target) {
options.board.value.addEventListener('wheel', eventHandlers.zoom)
//
options.board.value.addEventListener('mousedown', eventHandlers.dragStart)
options.board.value.addEventListener('mousemove', eventHandlers.dragMove)
options.board.value.addEventListener('mouseup', eventHandlers.dragEnd)
options.board.value.addEventListener('mouseleave', eventHandlers.dragEnd)
//
options.board.value.addEventListener('contextmenu', eventHandlers.contextmenu)
//
const resizeObserver = new ResizeObserver(async () => {
;[state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(
options.board.value
)
boardInfoRef.value = {
width: state.boardWidth,
height: state.boardHeight,
left: state.boardLeft,
top: state.boardTop,
}
options.onBoardChange && options.onBoardChange(boardInfoRef.value, { fitSize })
})
resizeObserver.observe(options.board.value)
}
}
上面除了给 可视区域节点 绑定 eventHandlers 内定义的处理方法外,这里还会通过 ResizeObserver 监听 可视区域节点 大小是否发生变化,如果发生变化,则通过 getSize 方法,获得它的大小信息,赋予 boardInfoRef,以及通过 事件 onBoardChange 返回。
getSize - 获取节点大小信息
// 获取元素大小
async function getSize(ele: HTMLElement | undefined): Promise<[number, number, number, number]> {
function inner(resolve: (res: [number, number, number, number]) => void) {
if (ele) {
const { left, top } = ele.getBoundingClientRect()
const [width, height] = [ele.clientWidth, ele.clientHeight]
resolve([width, height, left, top])
} else {
resolve([0, 0, 0, 0])
}
}
return new Promise((resolve) => {
if (ele) {
if (ele instanceof HTMLImageElement) {
if (ele.complete) {
inner(resolve)
} else {
ele.onload = () => {
inner(resolve)
}
}
} else {
inner(resolve)
}
} else {
resolve([0, 0, 0, 0])
}
})
}
这里主要利用 API getBoundingClientRect 去获得节点的大小信息,只是这里考虑了如果节点是图片,需要异步获得 onload 完成后的真实显示大小。
再次获取图片大小,将通过 img 的 complete 属性判断,为 true 则已经加载过了,则可以直接获取 img 的大小。
状态定义
// 状态值
const state = reactive({
lastLeft: 0, // 上次的left
lastTop: 0, // 上次的top
overX: 0, // 鼠标移动坐标x
overY: 0, // 鼠标移动坐标y
boardLeft: 0, // 容器区域距离浏览器左边距离
boardTop: 0, // 容器区域距离浏览器上边距离
startX: 0, // 长按开始坐标x
startY: 0, // 长按开始坐标y
isDown: false, // 鼠标是否长按中
moveX: 0, // 长按移动坐标x
moveY: 0, // 长按移动坐标y
boardWidth: 0, // 容器区域宽
boardHeight: 0, // 容器区高
targetWidth: 0, // 目标区域宽
targetHeight: 0, // 目标区域高
})
用于计算缩放大小、坐标,特别是 事件处理 逻辑。
fitSize - 自适应显示区域
// 自适应大小
async function fitSize(animate = false) {
const target = getTarget()
if (options.board.value && target) {
// 记录容器、目标大小
;[state.boardWidth, state.boardHeight, state.boardLeft, state.boardTop] = await getSize(
options.board.value
)
;[state.targetWidth, state.targetHeight] = await getSize(target)
//
// 是否需要动画缩放
if (animate) {
target.style.transition = 'all 0.3s ease-in'
}
// 计算 可视区域 和 目标 的比例(用于 自适应显示区域 计算)
const rateBoard = state.boardWidth / state.boardHeight
const rateTarget = state.targetWidth / state.targetHeight
// 计算 扣除内边距
const [boardWidth, boardHeight] = [
state.boardWidth - (options.padding?.[1] ?? 0) - (options.padding?.[3] ?? 0),
state.boardHeight - (options.padding?.[0] ?? 0) - (options.padding?.[2] ?? 0),
]
// 根据 可视区域 和 目标 的比例,横向/纵向 计算 zoom 缩放比例
if (rateBoard > rateTarget) {
zoom.value = boardHeight / state.targetHeight - 1
} else if (rateBoard < rateTarget) {
zoom.value = boardWidth / state.targetWidth - 1
}
// zoom 保留 2位 小数
zoom.value = Math.floor(zoom.value * 100) / 100
// 容错处理
if (zoom.value > 0) {
zoom.value = 0
}
// 根据 zoom、padding,计算横向位置
left.value = Math.round(
(boardWidth + (options.padding?.[3] ?? 0) - state.targetWidth * (1 + zoom.value)) / 2
)
// 根据 zoom、padding,计算纵向位置
top.value = Math.round(
(boardHeight + (options.padding?.[0] ?? 0) - state.targetHeight * (1 + zoom.value)) / 2
)
// 缓存位置信息(用于 事件处理)
state.lastLeft = left.value
state.lastTop = top.value
// 更新目标的样式
updateTargetStyle()
// 动画结束后,移除其 transition 样式
if (animate) {
setTimeout(() => {
if (target) {
target.style.transition = 'none'
}
}, 300)
}
}
}
细节说明,请留意注释文字
上面主要处理了:
- 计算位置、缩放比例
- 更新样式
- 动画处理
- 信息缓存
其实就是实现了类似 CSS 样式中大 object-fit: cover 效果!
updateTargetStyle - 更新目标样式
function updateTargetStyle() {
const target = getTarget()
if (target) {
target.style.transform = `scale(${zoom.value + 1})`
target.style.left = `${left.value}px`
target.style.top = `${top.value}px`
targetInfoRef.value = {
width: Math.round(state.targetWidth * (zoom.value + 1)),
height: Math.round(state.targetHeight * (zoom.value + 1)),
left: left.value,
top: top.value,
zoom: zoom.value,
}
options.onTargetChange && options.onTargetChange(targetInfoRef.value, { fitSize })
}
}
上面主要步骤:
- 通过 transform 的 scale 设置目标的 zoom 缩放比例
- 设置 left、top,作为目标的坐标
- 记录 Ref 信息
- 事件通知 onTargetChange
事件处理
const eventHandlers = {
zoom: (e: WheelEvent) => {
if (e.deltaY < 0) {
// 鼠标上滚 - 缩小
if (zoom.value <= options.zoomMax! - options.zoomSpeed!) {
changeZoom(options.zoomSpeed!)
}
} else if (e.deltaY > 0) {
// 鼠标下滚 - 放大
if (zoom.value >= options.zoomMin! - 1 + options.zoomSpeed!) {
changeZoom(-options.zoomSpeed!)
}
}
e.preventDefault()
},
contextmenu: (e: MouseEvent) => {
e.preventDefault()
},
dragStart: (e: MouseEvent) => {
// 右键
if (e.button === 0) {
// 记录鼠标坐标
state.startX = e.clientX
state.startY = e.clientY
// 按下状态
state.isDown = true
}
},
dragMove: (e: MouseEvent) => {
// 当前鼠标位置(没有按下,也需要记录,计算所需)
state.overX = e.clientX
state.overY = e.clientY
// 检查 按下状态
if (state.isDown) {
// 当前鼠标位置
state.moveX = e.clientX
state.moveY = e.clientY
// 计算拖拽后的坐标
left.value = Math.round(state.lastLeft + state.moveX - state.startX)
top.value = Math.round(state.lastTop + state.moveY - state.startY)
// 更新目标样式
updateTargetStyle()
}
},
dragEnd: () => {
// 鼠标离开状态
state.isDown = false
// 缓存坐标信息
state.lastLeft = left.value
state.lastTop = top.value
},
}
上面主要是通过事件的处理,改变目标的缩放比例和坐标位置。
changeZoom - 改变目标比例
// 放大缩小
function changeZoom(value: number) {
const target = getTarget()
if (options.board.value && target) {
// 上次的 大小
const lastTargetWidth = state.targetWidth * (1 + zoom.value)
const lastTargetHeight = state.targetHeight * (1 + zoom.value)
// 基于鼠标位置,计算上次的偏移量
const lastOffsetX = state.overX - state.lastLeft - state.boardLeft
const lastOffsetY = state.overY - state.lastTop - state.boardTop
// 偏移量 相对于 大小的 比例
const rateX = lastOffsetX / lastTargetWidth
const rateY = lastOffsetY / lastTargetHeight
// 更新缩放比例
zoom.value += value
zoom.value = Math.round(zoom.value * 100) / 100
// 最新的 大小
const newTargetWidth = state.targetWidth * (1 + zoom.value)
const newTargetHeight = state.targetHeight * (1 + zoom.value)
// 计算最新的偏移量
const newSpanX = newTargetWidth * rateX - lastOffsetX
const newSpanY = newTargetHeight * rateY - lastOffsetY
// 更新位置
left.value = Math.round(state.lastLeft - newSpanX)
top.value = Math.round(state.lastTop - newSpanY)
state.lastLeft = left.value
state.lastTop = top.value
// 更新样式
updateTargetStyle()
}
}
改变目标缩放比例,需要考虑当前的鼠标位置,基于该位置作为缩放中心。这将会同时影响 left、top 的坐标位置。
这里的逻辑需要一边调试一边看效果,才能更直观的理解!
切换鼠标 cursor
// 切换鼠标 cursor
watch(
() => state.isDown,
() => {
options.board.value &&
(options.board.value.style.cursor = state.isDown ? 'pointer' : 'default')
}
)
最后,根据状态是否按下,切换鼠标指针样式。
关于 hook useZoomDrag 的完整代码,可以直奔这里。
实现 组件
有了上面实现的 hook,基于它实现一个组件就很简单了:
import { ref } from 'vue'
import useZoomDrag from '@/lib/hooks/useZoomDrag'
import type { useZoomDragOptions, ZoomDragSize, ZoomDragMethods } from '../types'
// 保留部分 useZoomDrag 的配置项
const props = withDefaults(
defineProps<Pick<useZoomDragOptions, 'zoomSpeed' | 'zoomMax' | 'zoomMin' | 'padding'>>(),
{
zoomSpeed: () => 0.1,
zoomMax: () => 3,
zoomMin: () => 0.2,
}
)
// 组件即 可视区域
const boardRef = ref<HTMLElement>()
// 使用 useZoomDrag
const { target, board, methods } = useZoomDrag({
board: boardRef,
zoomSpeed: props.zoomSpeed,
zoomMax: props.zoomMax,
zoomMin: props.zoomMin,
padding: props.padding,
// 事件转换
onReady: () => {
emits('ready')
},
onTargetChange: (info: ZoomDragSize & { zoom: number }, methods: ZoomDragMethods) => {
emits('target-change', info, methods)
},
onBoardChange: (info: ZoomDragSize, methods: ZoomDragMethods) => {
emits('board-change', info, methods)
},
})
const emits = defineEmits(['ready', 'target-change', 'board-change'])
// 暴露方法
defineExpose(methods)
<template>
<div ref="boardRef">
<slot
v-bind="{
target,
board,
methods,
}"
></slot>
</div>
</template>
是不是很简单呢?
实现 指令
指令也比较简单,只是存在一些限制,例如无法暴露 fitSize 方法:
{
mounted: (el, { value }: { value: Omit<useZoomDragOptions, 'board' | 'target'> }) => {
useZoomDrag({
...value,
board: ref(el),
})
},
}
实现 插件
import { ref, type App } from 'vue'
import ZoomDrag from './components/ZoomDrag.vue'
import useZoomDrag from './hooks/useZoomDrag'
import type { useZoomDragOptions } from './types'
export default {
// 供 app.use 使用
install(app: App) {
// 全局注册 组件
app.component('ZoomDrag', ZoomDrag)
// 全局注册 指令
app.directive('zoom-drag', {
mounted: (el, { value }: { value: Omit<useZoomDragOptions, 'board' | 'target'> }) => {
useZoomDrag({
...value,
board: ref(el),
})
},
})
},
}
篇幅较长,希望达到手把手分享的效果。
可以直达👇
别忘了,可以带话,给我一个 Star 哟!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。