Vue3 + Canvas实现选中鼠标框选范围元素

在开发中遇到一个需求,类似windows的鼠标绘制一个框选区域,在框选区域中的元素被选中并且能够设置背景颜色,同时按下shift、ctrl键能够追加选中的元素,接下来的实现用到了Canvas封装的一些方法,可以参考之前的文章,链接如下:

Canvas扩展 判断点击位置是否位于绘制图形中 - 掘金 (juejin.cn)

1、初始化

因为框选的区域是针对整个浏览器的,所以画布的宽度要与浏览器可视区域的宽高保持一致。因为在鼠标移动和鼠标左键抬起时,也需要用到画布的相关方法,这时需要吧画布对象暴露给全局。具体代码如下:

html 复制代码
<template>
  <div class="wrapper">
    <canvas id="canvas" class="canvas"></canvas>
  </div>
</template>
typescript 复制代码
const drawer = ref<Drawer>()

const initCanvas = () => {
  const canvas = document.getElementById('canvas') as HTMLCanvasElement
  canvas.width = window.innerWidth - 20
  canvas.height = window.innerHeight - 20
  drawer.value = new Drawer({ view: canvas })
}

onMounted(() => {
  initCanvas()
})

到这里画布的初始化就完成了,然后添加一些元素用于框选,因为被框选的元素在canvas的上方,所以需要调整css布局,调整代码如下:

html 复制代码
<template>
  <div class="wrapper">
    <div class="rect-wrapper">
      <div v-for="i in 8" :key="i" :data-id="i" class="rect-style"></div>
    </div>
    <canvas id="canvas" class="canvas"></canvas>
  </div>
</template>
css 复制代码
.wrapper {
  height: 100vh;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
}
.rect-wrapper {
  height: 150px;
  width: 300px;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
}
.rect-style {
  width: 50px;
  height: 50px;
  background-color: #490eff;
  margin: 10px;
}
.canvas {
  position: absolute;
  top: 0;
  left: 0;
}

到这里,画布和被框选元素的准备工作完成。

2、绘制框选区域

鼠标左键按下时开始绘制,记录当前鼠标点击的坐标作为开始坐标,鼠标左键按下之后开始记录鼠标移动的坐标作为结束坐标,获取开始坐标和结束坐标后能够绘制鼠标框选区域,绘制之前需要先清除之前绘制的区域,当鼠标左键抬起时,清空画布。 由此可知,我们需要监听鼠标左键按下、移动、抬起三个事件,代码如下:

typescript 复制代码
type AreaPoint = {
  startX: number
  startY: number
  endX: number
  endY: number
}
const areaPoint = reactive<AreaPoint>({
  startX: 0,
  startY: 0,
  endX: 0,
  endY: 0
})

// 是否开始获取坐标
const startMove = ref(false)

const mouseDown = (e: MouseEvent) => {
  startMove.value = true
  const { x, y } = e
  areaPoint.startX = x
  areaPoint.startY = y
}
const mouseMove = (e: MouseEvent) => {
  if (startMove.value) {
    const { x, y } = e
    areaPoint.endX = x
    areaPoint.endY = y

    drawer.value?.clear()

    const { startX, startY, endX, endY } = areaPoint
    const rect = new Rect(
      { x: startX, y: startY, width: endX - startX, height: endY - startY, isFill: false },
      'rect'
    )

    drawer.value?.add(rect)
  }
}
const mouseUp = (e: MouseEvent) => {
  const { screenX, screenY } = e
  areaPoint.endX = screenX
  areaPoint.endY = screenY
  startMove.value = false
  drawer.value?.clear()
}

onMounted(() => {
  bindEvent()
  ...
})

onBeforeUnmount(() => {
  window.removeEventListener('mousedown', mouseDown)
  window.removeEventListener('mousemove', mouseMove)
  window.removeEventListener('mouseup', mouseUp)
})

3、选中框选区域中的元素

因为根据框选区域是根据坐标来计算的,所以需要在框选之前获取到所有的坐标及宽、高,代码如下:

typescript 复制代码
type RectInfo = {
  id: number
  x: number
  y: number
  width: number
  height: number
}

const rectInfoList = ref<RectInfo[]>([])

const getAllDomPoint = () => {
  const getAllDom = document.querySelector('.rect-wrapper')!.children
  for (const key of getAllDom) {
    const { x, y, width, height } = key.getBoundingClientRect()
    rectInfoList.value.push({
      id: Number((key as HTMLElement).dataset.id),
      x,
      y,
      width,
      height
    })
  }
}

onMounted(() => {
  getAllDomPoint()
  ...
})

根据鼠标的移动,需要判断被框选元素是否在框选区域中,鼠标框选示例如下:

由图中可以看出,框选区域主要有以上4类,为了方便处理,设置最终的开始坐标永远小于结束坐标,代码如下:

typescript 复制代码
const computedIsSelected = (areaPoint: AreaPoint, rectInfo: RectInfo) => {
  const { startX, startY, endX, endY } = areaPoint
  const { x, y, width, height } = rectInfo
  const finalStartX = startX > endX ? endX : startX
  const finalStartY = startY > endY ? endY : startY
  const finalEndX = startX > endX ? startX : endX
  const finalEndY = startY > endY ? startY : endY
}

示例图中的判断被框选元素是否在框选区域中需要根据框选元素的四个点的坐标,所以需要先计算四个点的坐标,代码如下:

typescript 复制代码
const { startX, startY, endX, endY } = areaPoint
const { x, y, width, height } = rectInfo
...
const rectPointTopLeft = {
  x,
  y
}
const rectPointTopRight = {
  x: x + width,
  y
}
const rectPointBottomLeft = {
  x,
  y: y + height
}
const rectPointBottomRight = {
  x: x + width,
  y: y + height
}

图例与判断条件的对应关系如下:

至此,在框选区域中的元素能够被选中,代码如下:

html 复制代码
<template>
  <div class="wrapper">
    <div class="rect-wrapper">
      <div
        ...
        :style="{
          backgroundColor: highRectList.find((item) => item.id === i) ? 'green' : ''
        }"
      ></div>
    </div>
  </div>
</template>
typescript 复制代码
const mouseMove = (e: MouseEvent) => {
  if (startMove.value) {
    ...
    rectInfoList.value.forEach((item) => {
      const { id } = item
      if (computedIsSelected(areaPoint, item)) {
        !highRectList.value.find((item) => item.id === id) &&
          highRectList.value.push({
            id
          })
      }
    })
    drawer.value?.add(rect)
  }
}

但是在鼠标框选还未抬起鼠标左键之前,有可能会缩小框选区域,缩小之前被选中的元素应该取消选中,这里通过遍历被选中的元素,过滤出在框选区域中的元素,代码如下:

typescript 复制代码
const mouseMove = (e: MouseEvent) => {
  if (startMove.value) {
   ...
    rectInfoList.value.forEach((item) => {
      const { id } = item
      if (computedIsSelected(areaPoint, item)) {
       ...
      } else {
        highRectList.value = highRectList.value.filter((item) => item.id !== id)
      }
    })
    drawer.value?.add(rect)
  }
}

至此,通过鼠标移动选中元素的功能基本完成。在系统中,按下shift然后继续框选,之前的应该也应该保留。

4、按下shift、ctrl继续框选

按下shift、ctrl继续框选是在第一次框选之后,所以在鼠标抬起时需要对选中的元素添加已选中的标记,修改代码如下:

typescript 复制代码
type RectInfo = {
  ...
  isSelected: boolean
}

const mouseUp = (e: MouseEvent) => {
  ...
  highRectList.value.forEach((item) => {
    item.isSelected = true
  })

添加键盘监听事件,判断shift、ctrl是否按下,代码如下:

typescript 复制代码
const keyIsPress = ref(false)

const keyDown = (e: KeyboardEvent) => {
  if (e.key === 'Shift') {
    keyIsPress.value = true
  }

  if (e.key === 'Control') {
    keyIsPress.value = true
  }
}
const keyUp = () => {
  keyIsPress.value = false
}

function bindEvent() {
  ...
  window.addEventListener('keydown', keyDown)
  window.addEventListener('keyup', keyUp)
}

修改鼠标移动时判断元素是否被框选的判断条件,如果元素中isSelected为true,说明已被选中,按下shift、ctrl再次选中时不清除,当按下shift、ctrl框选到已经选中的元素,isSelected设置为false,当缩小框选区域是能够取消选中,否则查找当前被选中的元素,修改代码如下:

typescript 复制代码
const mouseMove = (e: MouseEvent) => {
  if (startMove.value) {
    ...

    rectInfoList.value.forEach((item) => {
      const { id } = item
      if (computedIsSelected(areaPoint, item)) {
       const target = highRectList.value.find((item) => item.id === id)
        if (target) {
          target.isSelected = false
        } else {
          highRectList.value.push({
            id
          })
        }
      } else {
        const tempArr: HighRect[] = []
        highRectList.value.forEach((item) => {
          if (item.isSelected) {
            tempArr.push(item)
          } else {
            if (item.id !== id) {
              tempArr.push(item)
            }
          }
        })
        highRectList.value = [...tempArr]
      }
    })
    drawer.value?.add(rect)
  }
}

至此,鼠标移动框选的功能基本完成。

代码地址:

stackblitz.com/edit/vitejs...

相关推荐
zqx_712 分钟前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己28 分钟前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称1 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色1 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河1 小时前
CSS总结
前端·css
NiNg_1_2341 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦2 小时前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
BigYe程普2 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
神之王楠2 小时前
如何通过js加载css和html
javascript·css·html