在工作中經常遇到需要預覽一張尺寸可能非常大的圖片,初始化顯示的時候,希望它自適應顯示區域後,還可以縮放並可以在顯示區域中拖拽。
在這裏,手把手展示一下如何實現一個簡單的組件,以實現上述的需求。
效果展示
先看看效果
可以直達👇
示例倉庫 | 示例文檔 | 在線示例
別忘了,可以帶話,給我一個 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 喲!