实现一个简易的可拖拽缩放画布

最近,我遇到了一个的需求:设计并实现一个具备拖拽和缩放功能的画布,这在许多设计软件中是一个常见的功能,比如蓝湖等。在这篇文章中,我将分享一种简单而高效的方法来构建这样的画布功能。

画布将具备以下核心功能:

  • 拖拽:允许自由移动画布内的元素。
  • 缩放:使用鼠标滚轮来放大或缩小画布视图。
  • 定位:快速将视图定位到画布上的特定元素。

在线演示

为了让您更直观地体验这个拖拽缩放画布的功能,我已经将其完整实现并部署在 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)

让我们先来了解了解概念:

  1. 为什么需要位移补偿?

如果我们只是简单地改变 scale 而不进行位移补偿:

tsx 复制代码
// 错误示范:只改变缩放
element.style.transform = `translate(${x}px, ${y}px) scale(${newScale})`

如果我们只是简单地改变scale而不进行位移补偿,画布会以左上角(0,0)为中心点进行缩放,导致鼠标下的内容会向外"逃离"鼠标位置。位移补偿确保了缩放中心点在鼠标位置。

  1. 补偿公式分解
tsx 复制代码
dx = (mouseX - transformRef.current.x) * (1 - newScale/oldScale)

这是最关键的部分:

  • (mouseX - transformRef.current.x) 表示鼠标位置到当前变换原点的距离
  • (1 - newScale/oldScale) 是一个补偿因子
  1. 示例

假设有一个场景:

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数组,计算出所有页面元素的边界坐标minXmaxXminYmaxY

再利用上面计算出的边界坐标,我们可以得到整体可视区域的宽度(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>
  )
}

最终效果展示

总结

通过本文,我们学习如何实现一个具有拖拽、缩放和定位功能的简易画布。对于画布上的基础功能其实都知道该怎么去实现。

所以本文主要的难点或者说优化点在于:

  • 缩放的时候利用位移补偿计算,来确保在缩放时鼠标位置与视图保持一致
  • 定位功能和其居中定位计算,来提升体验,同时可以初始化自适应视口。
相关推荐
咖啡の猫2 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲4 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5815 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路5 小时前
GeoTools 读取影像元数据
前端
ssshooter5 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友6 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry6 小时前
Jetpack Compose 中的状态
前端
dae bal7 小时前
关于RSA和AES加密
前端·vue.js
柳杉7 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog7 小时前
低端设备加载webp ANR
前端·算法