SVG创建画布-缩放、平移、自适应窗口

canvas.gif

在线源码及预览地址:https://stackblitz.com/edit/vitejs-vite-q5jcer?file=README.md

主要构思就是一个SVG。新建对象都在这个SVG中,鼠标移动绘制Path。

这里主要讨论一下,平移缩放画布后的位置计算。

初始化,画布尺寸 [100vw,100vh]

鼠标位置 mouse

这里参考了 https://juejin.cn/post/6883353218319908871

  • MouseEvent.clientX 是只读属性, 它提供事件发生时的应用客户端区域的水平坐标 (与页面坐标不同)。例如,不论页面是否有水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX 值都将为 0 。
  • MouseEvent.clientY 是只读属性, 它提供事件发生时的应用客户端区域的垂直坐标 (与页面坐标不同)。例如,当你点击客户端区域的左上角时,鼠标事件的 clientY 值为 0 ,这一值与页面是否有垂直滚动无关。
  • MouseEvent.movementX 是只读属性,它提供了当前事件和上一个mousemove事件之间鼠标在水平方向上的移动值。换句话说,这个值是这样计算的 : currentEvent.movementX = currentEvent.screenX – previousEvent.screenX.
  • MouseEvent.movementY 是只读属性,它提供了当前事件和上一个 mousemove 事件之间鼠标在水平方向上的移动值。换句话说,这个值是这样计算的 :currentEvent.movementY = currentEvent.screenY – previousEvent.screenY.
  • MouseEvent.offsetX规定了事件对象与目标节点的内填充边(padding edge)在 X 轴方向上的偏移量。
  • MouseEvent.offsetY规定了事件对象与目标节点的内填充边(padding edge)在 Y 轴方向上的偏移量。
  • MouseEvent.pageX 是一个由MouseEvent接口返回的相对于整个文档的x(水平)坐标以像素为单位的只读属性。这个属性将基于文档的边缘,考虑任何页面的水平方向上的滚动。举个例子,如果页面向右滚动 200px 并出现了滚动条,这部分在窗口之外,然后鼠标点击距离窗口左边 100px 的位置,pageX 所返回的值将是 300。
  • MouseEvent.pageY是一个只读属性,它返回触发事件的位置相对于整个 document 的 Y 坐标值。由于其参考物是整个 dom,所以这个值受页面垂直方向的滚动影响。例如:当你垂直方向向下滚动了 50 pixel,那么你在顶端进行点击的时候,获取的pageY为 50pixed 而不是 0。
  • MouseEvent.screenX是只读属性,他提供了鼠标相对于屏幕坐标系的水平偏移量。
  • MouseEvent.screenY是只读属性,它提供了鼠标相对于屏幕坐标系的垂直偏移量。
  • MouseEvent.x 是 MouseEvent.clientX 属性的别名.
  • MouseEvent.y 属性是 MouseEvent.clientY 属性的别称。

offset.png

监听

这里监听的是 onpointerdown onpointermove onpointerup 代替鼠标的 mouse-事件,获取 pressure 值。

function handlePointerDown(e) {
  e.target.setPointerCapture(e.pointerId)
  const pt = eventToLocation(e)
  points.value = [[pt.x, pt.y, e.pressure]]
}

function handlePointerUp(e) {
  draggedEvt = null
}

function handlePointerMove(e) {
  if (isPressedCtrl.value) {
    // 移动画布
    drag(e)
  }
  else {
    if (e.buttons !== 1)
      return
    const pt = eventToLocation(e)
    points.value = [...points.value, [pt.x, pt.y, e.pressure]]
  }
}

上面三个方法,按下ctrl键再移动,就是拖拽画布,否则就是将这些点位添加到数组中,用来构造 path
当然,每个点需要经过 eventToLocation 方法计算过滤后再添加。
当画布没有拖拽移动的时候,其实经过处理后的坐标没变。拖拽之后,就会在鼠标的位置上添加偏移值。

// 拖拽方法
let draggedEvt: any = null // 每次的拖拽事件
const drag = (event: MouseEvent | TouchEvent) => {
  const pt = eventToLocation(event)
  if (draggedEvt) {
    const oriPt = eventToLocation(draggedEvt)
    const x = cfg.value.viewPortX + (oriPt.x - pt.x)
    const y = cfg.value.viewPortY + (oriPt.y - pt.y)
    const w = cfg.value.viewPortWidth
    const h = cfg.value.viewPortHeight
    updateViewPort(x, y, w, h)
  }
  draggedEvt = event
}

这里其实只是修改 viewBox 起点(前两个值)就可以满足拖拽视图。
首先计算出两次拖拽偏移的差值(鼠标两次值),然后再跟原来的相加就行。

平移计算偏移值

function eventToLocation(event: MouseEvent | TouchEvent, idx = 0): { x: number; y: number } {
  const { top, left } = useElementBounding(svgWrapperRef)
  const touch = event instanceof MouseEvent ? event : event.touches[idx]
  const x = cfg.value.viewPortX + (touch.clientX - left.value)
  const y = cfg.value.viewPortY + (touch.clientY - top.value)
  return { x, y }
}

这里使用了@vueuseuseElementBounding方法获取当前dom距离浏览器的距离。这个方法使用的是
Element.getBoundingClientRect()

images20221116144009.png

这里使用的clientX clientY,因为画布是占满浏览器的,所以这里pageX(Y) clientX(Y) offsetX(Y)值是相同的。

function updateViewPort(x: number, y: number, w: number | null, h: number | null, force = false) {
  if (!svgWrapperRef.value)
    return
  if (w === null && h !== null)
    w = svgWrapperRef.value.offsetWidth * h / svgWrapperRef.value.offsetHeight
  if (h === null && w !== null)
    h = svgWrapperRef.value.offsetHeight * w / svgWrapperRef.value.offsetWidth
  if (!w || !h)
    return
  cfg.value.viewPortX = parseFloat((1 * x).toPrecision(6))
  cfg.value.viewPortY = parseFloat((1 * y).toPrecision(6))
  cfg.value.viewPortWidth = parseFloat((1 * w).toPrecision(4))
  cfg.value.viewPortHeight = parseFloat((1 * h).toPrecision(4))
}

这里其实应该主要是赋值给 cfg ,然后更新视图。前面几个if判断,主要是为了保证画布的长宽比例要跟外面的盒子的长宽比一致。

缩放计算偏移值

经过上面的平移计算,主要得出几点

  • 通过调整 viewBox 更改视图
  • 每个点需要经过 eventToLocation 计算
  • 平移的点的偏移量主要是计算赋值给 viewBox 的前两个值,就是用上一个点的位置 + (这个位置和上个位置的差)

那么放大缩小其实也是一样,

  • 通过调整 viewBox 更改视图
  • 每个点需要经过 eventToLocation 计算
  • 缩放需要计算四个值
function handleWheel(e) {
  e.preventDefault()
  const scale = 1.005 ** e.deltaY
  const pt = eventToLocation(e)
  zoomViewPort(scale, pt)
}
function zoomViewPort(scale: number, pt?: { x: number; y: number }) {
  if (!pt)
    pt = { x: cfg.value.viewPortX + 0.5 * cfg.value.viewPortWidth, y: cfg.value.viewPortY + 0.5 * cfg.value.viewPortHeight }
  const x = cfg.value.viewPortX + ((pt.x - cfg.value.viewPortX) - scale * (pt.x - cfg.value.viewPortX))
  const y = cfg.value.viewPortY + ((pt.y - cfg.value.viewPortY) - scale * (pt.y - cfg.value.viewPortY))
  const w = scale * cfg.value.viewPortWidth
  const h = scale * cfg.value.viewPortHeight
  updateViewPort(x, y, w, h)
}

监听鼠标滚轮,然后计算当前的鼠标点的位置转成平面坐标。
zoomViewPort 计算四个值,然后调用 updateViewPort 赋值给 viewBox

const viewPortZoom = ref(1)
function eventToLocation(event: MouseEvent | TouchEvent, idx = 0): { x: number; y: number } {
  const { top, left } = useElementBounding(svgWrapperRef)
  const touch = event instanceof MouseEvent ? event : event.touches[idx]
  const x = cfg.value.viewPortX + (touch.clientX - left.value) * viewPortZoom.value
  const y = cfg.value.viewPortY + (touch.clientY - top.value) * viewPortZoom.value
  return { x, y }
}

这里 eventToLocation() 方法需要改变了,因为是缩放,之前平移的时候,只是相加减差值,这需要乘上缩放的值(默认为1)

然后 updateViewPort() 方法也需要改一下,之前只是设置了视图 viewBox 的值,现在多加一句设置zoom缩放的层级。
层级通过画布跟盒子的比例得出,长或者宽都行,这里使用的画布的宽和盒子的宽得出的比例。

function updateViewPort(x: number, y: number, w: number | null, h: number | null, force = false) {
  if (!svgWrapperRef.value)
    return
  if (w === null && h !== null)
    w = svgWrapperRef.value.offsetWidth * h / svgWrapperRef.value.offsetHeight
  if (h === null && w !== null)
    h = svgWrapperRef.value.offsetHeight * w / svgWrapperRef.value.offsetWidth
  if (!w || !h)
    return
  cfg.value.viewPortX = parseFloat((1 * x).toPrecision(6))
  cfg.value.viewPortY = parseFloat((1 * y).toPrecision(6))
  cfg.value.viewPortWidth = parseFloat((1 * w).toPrecision(4))
  cfg.value.viewPortHeight = parseFloat((1 * h).toPrecision(4))
  // 设置zoom层级
  viewPortZoom.value = cfg.value.viewPortWidth / svgWrapperRef.value.offsetWidth
}

监听窗口改变后的画布

做完上面的事情之后,会发现有的时候窗口大小修改,但是画布并没有做处理,这样使得再次绘制的时候,鼠标的位置计算有偏差。
这里进行的处理很简单,跟上步平移的操作类似,重新设置一下窗口的 viewBox 的后两个值。

const { width, height } = useElementBounding(svgWrapperRef)
watch([width, height], (newValue, oldValue) => {
  // 过滤一下第一次。因为监听的是dom的长宽,第一次肯定会触发,长宽从0到设置的值。
  // 这个时候的差值就是dom元素本身,而不是窗口偏移的值,所以这个时候跳出方法
  if (oldValue[0] === 0 && oldValue[1] === 0)
    return
  const distanceX = newValue[0] - oldValue[0]
  const distanceY = newValue[1] - oldValue[1]
  const x = cfg.value.viewPortX
  const y = cfg.value.viewPortY
  const w = cfg.value.viewPortWidth + distanceX
  const h = cfg.value.viewPortHeight + distanceY
  updateViewPort(x, y, w, h)
})

这里就只需监听窗口的长宽,然后计算差值,赋值给宽高就行了。
第一步的时候,因为dom元素会从0到设置的尺寸,也就是默认这个会走一次,这个时候的差值其实是尺寸本身,如果再设置viewBox的话,相当于重复多设置了长宽,这个时候跳出,避免这个问题。

初步测试没什么问题,但是如果先缩放,再改变画布尺寸大小,这样就会出现问题了。

const distanceX = newValue[0] - oldValue[0]
const distanceY = newValue[1] - oldValue[1]
const w = cfg.value.viewPortWidth + distanceX
const h = cfg.value.viewPortHeight + distanceY

在没有经过缩放的时候,viewPortWidthviewPortHeight 都是跟画布尺寸 1:1 的,这样的差值distanceX distanceY计算也是没问题。
但是缩放之后,viewPortWidthviewPortHeight 是经过 viewPortZoom 处理的。
如果只是上面的这样1:1的平移计算,经过缩放后,就会出现问题。只是改变窗口尺寸大小,画布尺寸就会被缩放。如果缩小的过多, w 和 h 为负值,赋值给 viewBox 报错。

const { width, height } = useElementBounding(svgWrapperRef)
watch([width, height], (newValue, oldValue) => {
  if (oldValue[0] === 0 && oldValue[1] === 0)
    return
  const distanceX = (newValue[0] - oldValue[0]) * viewPortZoom.value
  const distanceY = (newValue[1] - oldValue[1]) * viewPortZoom.value
  const x = cfg.value.viewPortX
  const y = cfg.value.viewPortY
  const w = cfg.value.viewPortWidth + distanceX
  const h = cfg.value.viewPortHeight + distanceY
  updateViewPort(x, y, w, h)
})

跟以上步骤的处理方法一致,计算差值的时候乘以缩放值。

全部代码如下:

<script setup lang="ts">
import type { StrokeOptions } from 'perfect-freehand'
import { getStroke } from 'perfect-freehand'
import { getSvgPathFromStroke } from '../../utils/index'
const options: StrokeOptions = {
  size: 10,
  thinning: 0.618,
  smoothing: 0.5,
  streamline: 0.5,
}
const points = ref<(number[] | {
  x: number
  y: number
  pressure?: number
})[]>([])
const pathData = ref('')
watch(() => points.value, () => {
  const stroke = getStroke(points.value, options)
  pathData.value = getSvgPathFromStroke(stroke)
})

const { Ctrl } = useMagicKeys()
const isPressedCtrl = ref(false)
watch(Ctrl, (v) => {
  isPressedCtrl.value = v
})
const svgWrapperRef = ref<HTMLElement>()
const cfg = ref({
  viewPortX: 0,
  viewPortY: 0,
  viewPortWidth: 0,
  viewPortHeight: 0,
})
const viewPortZoom = ref(1)
const setViewPort = () => {
  if (!svgWrapperRef.value)
    return
  cfg.value.viewPortWidth = svgWrapperRef.value.offsetWidth
  cfg.value.viewPortHeight = svgWrapperRef.value.offsetHeight
}
onMounted(() => {
  setViewPort()
})

let draggedEvt: any = null // 每次的拖拽事件
const drag = (event: MouseEvent | TouchEvent) => {
  const pt = eventToLocation(event)
  if (draggedEvt) {
    const oriPt = eventToLocation(draggedEvt)
    const x = cfg.value.viewPortX + (oriPt.x - pt.x)
    const y = cfg.value.viewPortY + (oriPt.y - pt.y)
    const w = cfg.value.viewPortWidth
    const h = cfg.value.viewPortHeight

    updateViewPort(x, y, w, h)
  }
  draggedEvt = event
}

function eventToLocation(event: MouseEvent | TouchEvent, idx = 0): { x: number; y: number } {
  const { top, left } = useElementBounding(svgWrapperRef)
  const touch = event instanceof MouseEvent ? event : event.touches[idx]
  const x = cfg.value.viewPortX + (touch.clientX - left.value) * viewPortZoom.value
  const y = cfg.value.viewPortY + (touch.clientY - top.value) * viewPortZoom.value
  return { x, y }
}

function updateViewPort(x: number, y: number, w: number | null, h: number | null, force = false) {
  if (!svgWrapperRef.value)
    return

  // 这里的w,h比例应该跟画布的比例相同。虽然在调用这个方法之前计算的w、h也是根据比例计算的。
  // 这里经过下面的计算不出意外得出的值应该跟其之前相同
  if (w === null && h !== null)
    w = svgWrapperRef.value.offsetWidth * h / svgWrapperRef.value.offsetHeight

  if (h === null && w !== null)
    h = svgWrapperRef.value.offsetHeight * w / svgWrapperRef.value.offsetWidth

  if (!w || !h)
    return
  cfg.value.viewPortX = parseFloat((1 * x).toPrecision(6))
  cfg.value.viewPortY = parseFloat((1 * y).toPrecision(6))
  cfg.value.viewPortWidth = parseFloat((1 * w).toPrecision(4))
  cfg.value.viewPortHeight = parseFloat((1 * h).toPrecision(4))
  viewPortZoom.value = cfg.value.viewPortWidth / svgWrapperRef.value.offsetWidth
}

function handlePointerDown(e) {
  e.target.setPointerCapture(e.pointerId)
  const pt = eventToLocation(e)
  points.value = [[pt.x, pt.y, e.pressure]]
}
function handlePointerMove(e) {
  if (isPressedCtrl.value) {
    // 移动画布
    drag(e)
  }
  else {
    if (e.buttons !== 1)
      return

    const pt = eventToLocation(e)
    points.value = [...points.value, [pt.x, pt.y, e.pressure]]
  }
}
function handlePointerUp(e) {
  draggedEvt = null
}

function handleWheel(e) {
  e.preventDefault()
  const scale = 1.005 ** e.deltaY
  const pt = eventToLocation(e)
  zoomViewPort(scale, pt)
}
function zoomViewPort(scale: number, pt?: { x: number; y: number }) {
  if (!pt)
    pt = { x: cfg.value.viewPortX + 0.5 * cfg.value.viewPortWidth, y: cfg.value.viewPortY + 0.5 * cfg.value.viewPortHeight }
  const x = cfg.value.viewPortX + ((pt.x - cfg.value.viewPortX) - scale * (pt.x - cfg.value.viewPortX))
  const y = cfg.value.viewPortY + ((pt.y - cfg.value.viewPortY) - scale * (pt.y - cfg.value.viewPortY))
  const w = scale * cfg.value.viewPortWidth
  const h = scale * cfg.value.viewPortHeight
  updateViewPort(x, y, w, h)
}

const { width, height } = useElementBounding(svgWrapperRef)
watch([width, height], (newValue, oldValue) => {
  // 过滤一下第一次。因为监听的是dom的长宽,第一次肯定会触发,长宽从0到设置的值。
  // 这个时候的差值就是dom元素本身,而不是窗口偏移的值,所以这个时候跳出方法
  if (oldValue[0] === 0 && oldValue[1] === 0)
    return

  const distanceX = (newValue[0] - oldValue[0]) * viewPortZoom.value
  const distanceY = (newValue[1] - oldValue[1]) * viewPortZoom.value
  const x = cfg.value.viewPortX
  const y = cfg.value.viewPortY
  const w = cfg.value.viewPortWidth + distanceX
  const h = cfg.value.viewPortHeight + distanceY

  updateViewPort(x, y, w, h)
})
</script>

<template>
  <div id="svgWrapperRef" ref="svgWrapperRef" class=" w-screen h-screen rounded-md overflow-hidden">
    <svg
      id="svgCanvasRef"
      ref="svgCanvasRef"
      :viewBox="`${cfg.viewPortX} ${cfg.viewPortY} ${cfg.viewPortWidth} ${cfg.viewPortHeight}`"
      class="w-full h-full"
      @pointerdown="handlePointerDown"
      @pointermove="handlePointerMove"
      @pointerup="handlePointerUp"
      @wheel="handleWheel"
    >
      <path :d="pathData" />
    </svg>
  </div>
</template>

<style lang="less" scoped>

</style>

export function getSvgPathFromStroke(stroke) {
  if (!stroke.length)
    return ''
  const d = stroke.reduce(
    (acc, [x0, y0], i, arr) => {
      const [x1, y1] = arr[(i + 1) % arr.length]
      acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2)
      return acc
    },
    ['M', ...stroke[0], 'Q'],
  )

  d.push('Z')
  return d.join(' ')
}

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容