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

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

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

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

在线演示

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

最终效果展示

总结

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

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

  • 缩放的时候利用位移补偿计算,来确保在缩放时鼠标位置与视图保持一致
  • 定位功能和其居中定位计算,来提升体验,同时可以初始化自适应视口。
相关推荐
不爱吃饭爱吃菜5 分钟前
uniapp微信小程序一键授权登录
前端·javascript·vue.js·微信小程序·uni-app
heart000_17 分钟前
从零开始打造个人主页:HTML/CSS/JS实战教程
javascript·css·html
90后小陈老师1 小时前
3D个人简历网站 5.天空、鸟、飞机
前端·javascript·3d
chenbin___1 小时前
react native text 显示 三行 超出部分 中间使用省略号
javascript·react native·react.js
漫路在线5 小时前
JS逆向-某易云音乐下载器
开发语言·javascript·爬虫·python
不爱吃糖的程序媛5 小时前
浅谈前端架构设计与工程化
前端·前端架构设计
BillKu6 小时前
Vue3 Element Plus 对话框加载实现
javascript·vue.js·elementui
郝YH是人间理想7 小时前
系统架构设计师案例分析题——web篇
前端·软件工程
Evaporator Core7 小时前
深入探索:Core Web Vitals 进阶优化与新兴指标
前端·windows
初遇你时动了情7 小时前
html js 原生实现web组件、web公共组件、template模版插槽
前端·javascript·html