最近,我遇到了一个的需求:设计并实现一个具备拖拽和缩放功能的画布,这在许多设计软件中是一个常见的功能,比如蓝湖等。在这篇文章中,我将分享一种简单而高效的方法来构建这样的画布功能。
画布将具备以下核心功能:
- 拖拽:允许自由移动画布内的元素。
- 缩放:使用鼠标滚轮来放大或缩小画布视图。
- 定位:快速将视图定位到画布上的特定元素。
在线演示
为了让您更直观地体验这个拖拽缩放画布的功能,我已经将其完整实现并部署在 StackBlitz 平台上。您可以通过以下链接直接访问并尝试这个交互式的画布示例:
UnoCSS Simple Canvas - StackBlitz
实现
概览
在深入实现细节之前,让我们先来审视一下整体的布局代码,这是构建我们可拖拽缩放画布的基础。
tsx
import { useRef } from 'react'
interface PageItem {
title: string
rect: {
x: number
y: number
width: number
height: number
}
}
// 定义页面元素的数组,每个元素包含标题和位置尺寸信息
const pages: PageItem[] = [
{
title: 'Page 1',
rect: { x: 200, y: 100, width: 200, height: 150 },
},
{
title: 'Page 2',
rect: { x: 500, y: 300, width: 200, height: 150 },
},
{
title: 'Page 3',
rect: { x: 800, y: 200, width: 200, height: 150 },
},
{
title: 'Page 4',
rect: { x: 350, y: 500, width: 200, height: 150 },
},
]
function App() {
const canvasRef = useRef<HTMLDivElement>(null)
return (
<div ref={canvasRef} className='w-full h-full' style={{ transformOrigin: '0 0' }}>
{pages.map(page => (
<div
key={page.title}
className='absolute bg-white rounded-lg shadow-lg p-4 border border-gray-200 cursor-pointer select-none'
style={{
top: page.rect.y,
left: page.rect.x,
width: page.rect.width,
height: page.rect.height,
}}
>
<h3 className='text-lg font-semibold text-gray-800 mb-2'>{page.title}</h3>
<p className='text-gray-600'>Some content here...</p>
</div>
))}
</div>
)
}
transformOrigin: '0 0'
:我们给画布设置了transformOrigin
属性为'0 0'
,这确保了所有的变换(如拖拽和缩放)都是基于画布的左上角进行的,这与事件的 x,y 坐标系相匹配,使得逻辑更加直观。PageItem
:我们定义了一个PageItem
接口来描述每个页面元素的属性,包括标题和位置尺寸信息。这些属性将在后续的拖拽和缩放功能中被用到。
下面是我们的画布布局的截图,展示了页面元素的初始布局:
接下来,我们将逐步实现拖拽和缩放功能。从简单的拖拽开始,逐步深入到缩放功能的实现。
拖拽
拖拽是用户与画布交互的基础功能之一,它允许用户自由移动画布上的内容。
这个功能非常的常见和通用,直接这一功能的详细代码和解释:
tsx
// 用于记录 canvas 的偏移量信息
const transformRef = useRef({ x: 0, y: 0 })
useEffect(() => {
const canvasElement = canvasRef.current!
// 拖拽
let isDragging = false
let startX = 0
let startY = 0
let startTranslateX = 0
let startTranslateY = 0
const mouseDown = (e: MouseEvent) => {
if (e.button !== 0) {
return
}
isDragging = true
// 记录鼠标按下时的初始位置
startX = e.clientX
startY = e.clientY
// 记录元素当前的偏移量
startTranslateX = transformRef.current.x
startTranslateY = transformRef.current.y
// throttle
const mouseMove = throttle((e: MouseEvent) => {
if (isDragging) {
const offsetX = e.clientX - startX
const offsetY = e.clientY - startY
transformRef.current.x = startTranslateX + offsetX
transformRef.current.y = startTranslateY + offsetY
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px))`
}
}, 16)
const mouseUp = () => {
isDragging = false
document.removeEventListener('mouseup', mouseUp)
document.removeEventListener('mousemove', mouseMove)
}
document.addEventListener('mousemove', mouseMove)
document.addEventListener('mouseup', mouseUp)
}
canvasElement.addEventListener('mousedown', mouseDown)
return () => {
canvasElement.removeEventListener('mousedown', mouseDown)
}
}, [])
通过 transformRef
来存储画布的偏移量信息,避免在每次事件触发时都查询 DOM 元素,提高性能。
缩放
缩放功能允许用户通过Ctrl + 滚轮
操作来放大或缩小画布。这一功能对于查看细节或调整整体布局非常有用。
阻止浏览器默认行为
首先,我们需要阻止浏览器对Ctrl + 滚轮
的默认缩放行为,以确保我们的画布可以正确响应缩放操作。
tsx
useEffect(() => {
// 阻止浏览器默认缩放行为
const wheel = (e: WheelEvent) => {
if (e.ctrlKey === true || e.metaKey) {
e.preventDefault()
}
}
// 阻止浏览器默认右击行为
const contextMenu = (e: MouseEvent) => {
e.preventDefault()
}
viewportRef.current?.addEventListener('wheel', wheel)
viewportRef.current?.addEventListener('contextmenu', contextMenu)
return () => {
viewportRef.current?.removeEventListener('wheel', wheel)
viewportRef.current?.removeEventListener('contextmenu', contextMenu)
}
}, [])
接下来,我们实现缩放功能。我们定义了一些常量来控制缩放的行为:
WHEEL_RATIO
:每次缩放的基数。WHEEL_MAX_SCALE
:最大缩放倍数。WHEEL_MIN_SCALE
:最小缩放倍数。
tsx
// 滚动缩放基数
const WHEEL_RATIO = 0.2
// 缩放最大倍数
const WHEEL_MAX_SCALE = 10
// 缩放最小倍数
const WHEEL_MIN_SCALE = 0.5
// 新增 scale
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
useEffect(() => {
const canvasElement = canvasRef.current!
const canvasRect = canvasElement.getBoundingClientRect()
const wheel = throttle((e: WheelEvent) => {
if (e.ctrlKey) {
const oldScale = transformRef.current.scale
// 根据滚动方向来决定缩放大小
const newScale = e.deltaY > 0 ? oldScale - WHEEL_RATIO : oldScale + WHEEL_RATIO
// 缩放边界处理
if (newScale <= WHEEL_MIN_SCALE || newScale >= WHEEL_MAX_SCALE) {
return
}
const mouseX = e.clientX - canvasRect.left
const mouseY = e.clientY - canvasRect.top
// 位移补偿
const dx = (mouseX - transformRef.current.x) * (1 - newScale / oldScale)
const dy = (mouseY - transformRef.current.y) * (1 - newScale / oldScale)
transformRef.current.scale = newScale
transformRef.current.x += dx
transformRef.current.y += dy
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px) scale(${transformRef.current.scale})`
}
}, 16)
// 缩放
canvasElement.addEventListener('wheel', wheel)
return () => {
canvasElement.removeEventListener('wheel', mouseDown)
}
}, [])
去除画布偏移值
首先,我们需要计算鼠标相对于画布左上角的位置,这需要去除画布本身的偏移值:
tsx
const mouseX = e.clientX - canvasRect.left
const mouseY = e.clientY - canvasRect.top
这两行代码的目的是为了去除画布本身的偏移值,因为画布可能并不总是从浏览器视窗的左上角开始。
接着,我们需要思考一个问题,如何在缩放的时候,实现鼠标跟手的状态。
因为缩放后,画布整体进行了放大,但是位置还是原本的位置,这时候就会出现鼠标不跟手的清空,或者说画面出现了抖动。
所以要时候鼠标跟手,这里就需要对原本的偏移值进行额外的处理了。
位移补偿计算
位移补偿的目的是确保在缩放时,鼠标位置与视图保持一致。
tsx
const dx = (mouseX - transformRef.current.x) * (1 - newScale / oldScale)
const dy = (mouseY - transformRef.current.y) * (1 - newScale / oldScale)
让我们先来了解了解概念:
- 为什么需要位移补偿?
如果我们只是简单地改变 scale
而不进行位移补偿:
tsx
// 错误示范:只改变缩放
element.style.transform = `translate(${x}px, ${y}px) scale(${newScale})`
如果我们只是简单地改变scale
而不进行位移补偿,画布会以左上角(0,0)为中心点进行缩放,导致鼠标下的内容会向外"逃离"鼠标位置。位移补偿确保了缩放中心点在鼠标位置。
- 补偿公式分解
tsx
dx = (mouseX - transformRef.current.x) * (1 - newScale/oldScale)
这是最关键的部分:
(mouseX - transformRef.current.x)
表示鼠标位置到当前变换原点的距离(1 - newScale/oldScale)
是一个补偿因子
- 示例
假设有一个场景:
tsx
// 初始状态
mouseX = 100
transformRef.current.x = 50
oldScale = 1.0
newScale = 2.0
// 计算补偿
distance = mouseX - transformRef.current.x // 100 - 50 = 50
compensationFactor = 1 - newScale / oldScale // 1 - 2/1 = -1
dx = distance * compensationFactor // 50 * (-1) = -50
// 应用新位置
newX = transformRef.current.x + dx // 50 + (-50) = 0
缩放前
tsx
[原点] ----50px---- [变换位置] ----50px---- [鼠标位置]
0 50 100
缩放后(2 倍)
如果不补偿,50px 会变成 100px,鼠标下的点会偏离,补偿-50px 后,确保鼠标位置下的点保持不动。
tsx
[原点] -----100px----- [鼠标位置]
0 100
- 放大时(
newScale > oldScale
):补偿因子为负,产生向原点的位移,抵消了放大造成的距离增加。 - 缩小时(
newScale < oldScale
):补偿因子为正,产生远离原点的位移,抵消了缩小造成的距离减少。
缩放功能的实际效果展示:
优化缩放和定位
到目前为止,我们实现了缩放和定位,但是有个很严重的问题,就是如果我们的画布被缩放的太小,或者拖拽的太远,导致我们触发不了事件了,就会出现问题。
为了解决这个问题,我们在画布上层添加了一层蒙版(viewportRef
),专门用来接收事件,并将处理结果回显到画布上。
tsx
function App() {
const viewportRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
useEffect(() => {
const viewportElement = viewportRef.current!
const canvasElement = canvasRef.current!
const canvasRect = canvasElement.getBoundingClientRect()
// throttle
const wheel = throttle((e: WheelEvent) => {
// ...
}, 16)
// update
viewportElement.addEventListener('wheel', wheel)
// 拖拽
let isDragging = false
let startX = 0
let startY = 0
let startTranslateX = 0
let startTranslateY = 0
const mouseDown = (e: MouseEvent) => {
// ...
}
// update
viewportElement.addEventListener('mousedown', mouseDown)
return () => {
// update
viewportElement.removeEventListener('wheel', wheel)
viewportElement.removeEventListener('mousedown', mouseDown)
}
}, [])
// ...
return (
<div ref={viewportRef} className='w-full h-full bg-gray-100'>
<div ref={canvasRef} className='w-full h-full bg-gray-100' style={{ transformOrigin: '0 0' }}>
{/* ... */}
</div>
</div>
)
}
export default App
通过将事件监听器绑定到viewportRef
而不是canvasRef
,我们可以确保即使画布移动或缩放,事件也能被正确触发。以下是优化后的代码实现:
通过这种方式,我们确保了即使画布被缩放或移动,用户的操作也能被正确响应。以下是优化后的效果展示:
定位
为了实现快速定位到画布内特定Page
的功能,我们需要利用之前定义的PageItem.rect
属性,这些属性包含了页面元素的位置信息。
我们定义了一个locate
方法来实现定位功能,它接收两个参数:需要定位的Page
数组和定位动画的时间长度。
tsx
// 定位边距倍数
const LOCATE_PADDING = 1.2
// 定位最大倍数
const LOCATE_MAX_SCALE = 4
const locate = (pages: PageItem[], duration = 300) => {
if (!pages.length) return
const viewportElement = viewportRef.current!
const canvasElement = canvasRef.current!
const viewportWidth = viewportElement.clientWidth
const viewportHeight = viewportElement.clientHeight
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const page of pages) {
const { x, y, width, height } = page!.rect
minX = Math.min(minX, x)
maxX = Math.max(maxX, x + width)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y + height)
}
// 计算出可视区域的 rect 信息
const viewWidth = maxX - minX
const viewHeight = maxY - minY
// 根据视口重新计算缩放大小
const scaleX = viewportWidth / (viewWidth * LOCATE_PADDING)
const scaleY = viewportHeight / (viewHeight * LOCATE_PADDING)
transformRef.current.scale = Math.min(scaleX, scaleY, LOCATE_MAX_SCALE)
// 实现居中定位
transformRef.current.x = viewportWidth / 2 - (minX + viewWidth / 2) * transformRef.current.scale
transformRef.current.y = viewportHeight / 2 - (minY + viewHeight / 2) * transformRef.current.scale
canvasElement.style.transition = `transform ${duration}ms linear`
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px) scale(${transformRef.current.scale})`
setTimeout(() => {
canvasElement.style.transition = ''
}, duration)
}
在实现定位功能时,我们首先需要确定需要定位的页面元素(pages
)的整体位置和尺寸,以便计算出合适的缩放比例和偏移量,确保这些元素能够在视口中完全展示并居中定位。 首先,我们根据提供的pages
数组,计算出所有页面元素的边界坐标minX
、maxX
、minY
、maxY
。
再利用上面计算出的边界坐标,我们可以得到整体可视区域的宽度(viewWidth
)和高度(viewHeight
)。
接下来,我们需要计算水平和垂直方向的缩放比例,以确保内容能够完全显示在视口中。同时,我们乘以LOCATE_PADDING
来在边缘添加内边距,避免内容紧贴边缘。
然后,我们取两者的最小值作为最终的缩放比例,并确保这个比例不会超过LOCATE_MAX_SCALE
,以防止过度放大。
最后,我们应用计算出的缩放比例和偏移量,使页面元素居中显示,并加入一个动画过渡效果。动画完成后,我们移除过渡效果,避免影响后续的拖拽操作。
居中定位计算
为了使定位后的页面元素居中显示,我们需要重新计算视口位置,使可视区域居中显示:
tsx
viewportWidth / 2 - (minX + viewWidth / 2) * transformRef.current.scale
我们将其拆开来进行看:
tsx
// 1. 找到视口的中心点
const viewportCenterX = viewportWidth / 2
// 2. 找到内容的中心点
const contentCenterX = minX + viewWidth / 2
// 3. 因为内容被缩放了,所以内容中心点要乘以缩放比例
const scaledContentCenterX = contentCenterX * transformRef.current.scale
// 4. 计算需要的偏移量
const offsetX = viewportCenterX - scaledContentCenterX
举个例子:
假设视口宽度是 1000px,那么视口中心点是 500px,内容最左边(minX
)是 100px,内容总宽度(viewWidth
)是 400px,那么内容的中心点就是 100 + 400 / 2 = 300px
。
假设缩放比例是 1.5,那么缩放后的内容中心点是 300 * 1.5 = 450px
。
所以需要的偏移量是 500 - 450 = 50px
。(正值表示向右移动,负值表示向左移动)
初始化视口定位
因为所有的 pages
所处的位置使用默认偏移视口的话可能并不能展示完整。
现在有了定位函数,我们就可以将所有的 pages
塞入进去,重新计算一个可见视口位置。
tsx
const resetViewport = (duration = 300) => {
locate(pages, duration)
}
useEffect(() => {
resetViewport(600)
}, [])
Page 定位
接着再来给 Page
添加定位,这里使用双击的方式去定位 Page
。
tsx
function App() {
// ...
return (
<div ref={viewportRef} className='w-full h-full bg-gray-100' onDoubleClick={() => resetViewport()}>
<div ref={canvasRef} className='w-full h-full bg-gray-100' style={{ transformOrigin: '0 0' }}>
{pages.map(page => (
<div
key={page.title}
className='absolute bg-white rounded-lg shadow-lg p-4 border border-gray-200 cursor-pointer select-none'
style={{
top: page.rect.y,
left: page.rect.x,
width: page.rect.width,
height: page.rect.height,
}}
onDoubleClick={e => {
e.stopPropagation()
locate([page])
}}
>
<h3 className='text-lg font-semibold text-gray-800 mb-2'>{page.title}</h3>
<p className='text-gray-600'>Some content here...</p>
</div>
))}
</div>
</div>
)
}
这里我们为 Page
添加了定位,同时在双击画布的时候去重置视口。
因为两边都添加了双击事件,所以需要对 Page
的双击做额外的处理,来防止上层画布的事件触发:
tsx
e.stopPropagation()
完整代码
完整代码
tsx
import { throttle } from 'lodash-es'
import { useEffect, useRef } from 'react'
interface PageItem {
title: string
rect: {
x: number
y: number
width: number
height: number
}
}
const pages: PageItem[] = [
{
title: 'Page 1',
rect: { x: 200, y: 100, width: 200, height: 150 },
},
{
title: 'Page 2',
rect: { x: 500, y: 300, width: 200, height: 150 },
},
{
title: 'Page 3',
rect: { x: 800, y: 200, width: 200, height: 150 },
},
{
title: 'Page 4',
rect: { x: 350, y: 500, width: 200, height: 150 },
},
]
// 滚动缩放基数
const WHEEL_RATIO = 0.2
// 缩放最大倍数
const WHEEL_MAX_SCALE = 10
// 缩放最新倍数
const WHEEL_MIN_SCALE = 0.5
// 定位边距倍数
const LOCATE_PADDING = 1.2
// 定位最大倍数
const LOCATE_MAX_SCALE = 4
function App() {
const viewportRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
const locate = (pages: PageItem[], duration = 300) => {
if (!pages.length) return
const viewportElement = viewportRef.current!
const canvasElement = canvasRef.current!
const viewportWidth = viewportElement.clientWidth
const viewportHeight = viewportElement.clientHeight
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const page of pages) {
const { x, y, width, height } = page!.rect
minX = Math.min(minX, x)
maxX = Math.max(maxX, x + width)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y + height)
}
// 计算出可视区域的 rect 信息
const viewWidth = maxX - minX
const viewHeight = maxY - minY
// 根据视口重新计算缩放大小
const scaleX = viewportWidth / (viewWidth * LOCATE_PADDING)
const scaleY = viewportHeight / (viewHeight * LOCATE_PADDING)
transformRef.current.scale = Math.min(scaleX, scaleY, LOCATE_MAX_SCALE)
// 实现居中定位
transformRef.current.x = viewportWidth / 2 - (minX + viewWidth / 2) * transformRef.current.scale
transformRef.current.y = viewportHeight / 2 - (minY + viewHeight / 2) * transformRef.current.scale
canvasElement.style.transition = `transform ${duration}ms linear`
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px) scale(${transformRef.current.scale})`
setTimeout(() => {
canvasElement.style.transition = ''
}, duration)
}
const resetViewport = (duration = 300) => {
locate(pages, duration)
}
useEffect(() => {
resetViewport(600)
// 阻止浏览器默认缩放行为
const wheel = (e: WheelEvent) => {
if (e.ctrlKey === true || e.metaKey) {
e.preventDefault()
}
}
// 阻止浏览器默认右击行为
const contextMenu = (e: MouseEvent) => {
e.preventDefault()
}
viewportRef.current?.addEventListener('wheel', wheel)
viewportRef.current?.addEventListener('contextmenu', contextMenu)
return () => {
viewportRef.current?.removeEventListener('wheel', wheel)
viewportRef.current?.removeEventListener('contextmenu', contextMenu)
}
}, [])
useEffect(() => {
const viewportElement = viewportRef.current!
const canvasElement = canvasRef.current!
const canvasRect = canvasElement.getBoundingClientRect()
// throttle
const wheel = throttle((e: WheelEvent) => {
if (e.ctrlKey) {
const oldScale = transformRef.current.scale
// 根据滚动方向来决定缩放大小
const newScale = e.deltaY > 0 ? oldScale - WHEEL_RATIO : oldScale + WHEEL_RATIO
// 缩放边界处理
if (newScale <= WHEEL_MIN_SCALE || newScale >= WHEEL_MAX_SCALE) {
return
}
const mouseX = e.clientX - canvasRect.left
const mouseY = e.clientY - canvasRect.top
// 位移补偿
const dx = (mouseX - transformRef.current.x) * (1 - newScale / oldScale)
const dy = (mouseY - transformRef.current.y) * (1 - newScale / oldScale)
transformRef.current.scale = newScale
transformRef.current.x += dx
transformRef.current.y += dy
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px) scale(${transformRef.current.scale})`
}
}, 16)
// 缩放
viewportElement.addEventListener('wheel', wheel)
// 拖拽
let isDragging = false
let startX = 0
let startY = 0
let startTranslateX = 0
let startTranslateY = 0
const mouseDown = (e: MouseEvent) => {
if (e.button !== 0) {
return
}
isDragging = true
// 记录鼠标按下时的初始位置
startX = e.clientX
startY = e.clientY
// 记录元素当前的偏移量
startTranslateX = transformRef.current.x
startTranslateY = transformRef.current.y
// throttle
const mouseMove = throttle((e: MouseEvent) => {
if (isDragging) {
const offsetX = e.clientX - startX
const offsetY = e.clientY - startY
transformRef.current.x = startTranslateX + offsetX
transformRef.current.y = startTranslateY + offsetY
canvasElement.style.transform = `translate(${transformRef.current.x}px, ${transformRef.current.y}px) scale(${transformRef.current.scale})`
}
}, 16)
const mouseUp = () => {
isDragging = false
document.removeEventListener('mouseup', mouseUp)
document.removeEventListener('mousemove', mouseMove)
}
document.addEventListener('mousemove', mouseMove)
document.addEventListener('mouseup', mouseUp)
}
viewportElement.addEventListener('mousedown', mouseDown)
return () => {
viewportElement.removeEventListener('wheel', wheel)
viewportElement.removeEventListener('mousedown', mouseDown)
}
}, [])
return (
<div ref={viewportRef} className='w-full h-full bg-gray-100' onDoubleClick={() => resetViewport()}>
<div ref={canvasRef} className='w-full h-full bg-gray-100' style={{ transformOrigin: '0 0' }}>
{pages.map(page => (
<div
key={page.title}
className='absolute bg-white rounded-lg shadow-lg p-4 border border-gray-200 cursor-pointer select-none'
style={{
top: page.rect.y,
left: page.rect.x,
width: page.rect.width,
height: page.rect.height,
}}
onDoubleClick={e => {
e.stopPropagation()
locate([page])
}}
>
<h3 className='text-lg font-semibold text-gray-800 mb-2'>{page.title}</h3>
<p className='text-gray-600'>Some content here...</p>
</div>
))}
</div>
</div>
)
}
最终效果展示
总结
通过本文,我们学习如何实现一个具有拖拽、缩放和定位功能的简易画布。对于画布上的基础功能其实都知道该怎么去实现。
所以本文主要的难点或者说优化点在于:
- 缩放的时候利用位移补偿计算,来确保在缩放时鼠标位置与视图保持一致
- 定位功能和其居中定位计算,来提升体验,同时可以初始化自适应视口。