react图片缩放、平移(position、transform实现)上篇讲到了使用position、transform来实现图片的平移缩放。本篇将采用canvas来实现看看。

canvas

HTML5 <canvas> 标签用于绘制图像(通过脚本,通常是 JavaScript)。 不过,<canvas> 元素本身并没有绘制能力(它仅仅是图形的容器) - 您必须使用脚本来完成实际的绘图任务。下面列举本文所用的API

getContext():返回一个对象,该对象提供了用于在画布上绘图的方法和属性

getContext(contextId: "2d", options?: CanvasRenderingContext2DSettings): CanvasRenderingContext2D | null;
getContext(contextId: "bitmaprenderer", options?: ImageBitmapRenderingContextSettings): ImageBitmapRenderingContext | null;
getContext(contextId: "webgl", options?: WebGLContextAttributes): WebGLRenderingContext | null;
getContext(contextId: "webgl2", options?: WebGLContextAttributes): WebGL2RenderingContext | null;
getContext(contextId: string, options?: any): RenderingContext | null;

clearRect():在给定的矩形内清除指定的像素

clearRect(x: number, y: number, w: number, h: number): void;

drawImage():向画布上绘制图像、画布或视频

drawImage(image: CanvasImageSource, dx: number, dy: number): void;
drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
drawImage(image: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;

strokeStyle:设置或返回用于笔触的颜色、渐变或模式
strokeRect():绘制矩形(无填充)

strokeStyle: string | CanvasGradient | CanvasPattern;
strokeRect(x: number, y: number, w: number, h: number): void;

定义常量

定义一些变量模拟接口数据:

/** canvas大小 */
const WIDTH = 466;
const HEIGHT = 326;

/** 接口返回的图片坐标是相对于原图左上角的定位 */
const imgInfo = {
  lableBottom: "492",
  lableLeft: "342",
  lableRight: "353",
  lableTop: "470",
  position: "03",
  src: 'https://parts-images.cassmall.com/bmw_test/322664.jpg?version=16',
}

imgInfo对象用于模拟接口返回的图片信息,图标lableLeft、lableTop是相对于原图左上角的定位。

初始绘画图片

image.png

const Canvas = () => {

  /** 图片节点 */
  const [imgElement, setImgElement] = useState<HTMLImageElement>(new Image());

  /** 初始化图片位置 */
  const initImg = () => {
    /** 初始化一个图片节点对象 */
    const img: HTMLImageElement = new Image();
    img.onload = () => {
      const ctx = canvasRef.current?.getContext('2d') as CanvasRenderingContext2D;
      const { naturalWidth, height, naturalHeight } = img;
      // 缩放比例
      const imgScale = height / naturalHeight;
      // 图片设定的宽度
      const width = naturalWidth * imgScale;
      // 图片相对于父元素水平垂直居中的定位
      const left = WIDTH / 2 - width / 2;
      const top = HEIGHT / 2 - height / 2;
      // 画出图片
      ctx.drawImage(img, left, top, width, height);
      // 记录下图片节点,后续需要用到
      setImgElement(img);
    }
    img.height = HEIGHT;
    img.src = imgInfo.src;
  }
  
  useEffect(() => {
    /** 初始化图片 */
    initImg();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className={styles.imgArea}>
      <canvas 
        ref={canvasRef} 
        width={WIDTH} 
        height={HEIGHT} 
      ></canvas>
    </div>
  )
}

上面使用图片的onload API是因为如果图片没有加载完,是获取不到图片的真实宽度合高度。

在获取到图片元素节点对象后记录到state中,后续方法中需要用到节点对象中的宽高属性。

图片平移

图片平移可以监听这三个事件实现:onMouseDown、onMouseMove、onMouseUp
onMouseDown事件记录每次鼠标按下的坐标位置;
onMouseMove事件计算出每次平移的距离,该距离加上拖动前图片距离原点的距离就等于拖动后图片相对于原点的距离;
onMouseUp事件触发时,注销或者不让执行onMouseDown、onMouseMove事件,防止只要鼠标移入图片就会平移。

image.png

鼠标事件对象event: React.MouseEvent<HTMLCanvasElement>拿到的clientX, clientY是相对于浏览器的距离,为了更好理解,将其距离转化成相对于Canvas原点的距离:

/** 将浏览器坐标系转化成canvas坐标系 */
const windowToCanvas = (canvas: HTMLCanvasElement, x: number, y: number) => {
    var canvasBox = canvas.getBoundingClientRect();
    return {
        x: (x - canvasBox.left) * (canvas.width/canvasBox.width), // //对canvas元素大小与绘图表面大小不一致时进行缩放
        y: (y - canvasBox.top) * (canvas.height/canvasBox.height),
    };
}

<canvas>绑定鼠标事件:

<canvas 
    ref={canvasRef} 
    width={WIDTH} 
    height={HEIGHT} 
    onMouseDown={handleMouseDown}
    onMouseMove={handleMouseMove}
    onMouseUp={handleMouseUp}
></canvas>

handleMouseDown事件负责获取鼠标按下的坐标数据,并记录到state

/** 记录鼠标是否按下 */
const [mouseDowmFlag, setMouseDowmFlag] = useState(false);
/** 记录鼠标按下的坐标 */
const [mouseDowmPos, setMouseDowmPos] = useState<{x: number, y: number}>({x: 0, y: 0});

const handleMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
  event.stopPropagation();
  event.preventDefault(); // 阻止浏览器默认行为,拖动会打开图片
  const { clientX, clientY } = event;
  // 相对于canvas坐标
  const canvas = canvasRef.current as HTMLCanvasElement;
  const pos = windowToCanvas(canvas, clientX, clientY);
  canvas.style.cursor = 'move';
  setMouseDowmFlag(true); // 控制只有在鼠标按下后才会执行mousemove
  setMouseDowmPos({
    x: pos.x,
    y: pos.y,
  });
};

handleMouseMove事件负责获取到鼠标移动的坐标数据,并根据移动坐标数据、按下坐标数据计算出平移偏移量,用偏移量加上上次图片坐标就等于平移后的坐标,清空画布,再次绘图,更新按下的坐标和上次图片绘画的坐标到state中。

/** 记录这次平移之前的坐标 */
const [offsetDis, setOffsetDis] = useState<{left: number, top: number}>({left: 0, top: 0});

const handleMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault();
    if (!mouseDowmFlag) return;
    const { clientX, clientY } = event;
    const canvas = canvasRef.current as HTMLCanvasElement;
    // 相对于canvas坐标
    const pos = windowToCanvas(canvas, clientX, clientY)
    // 偏移量
    const diffX = pos.x - mouseDowmPos.x;
    const diffY = pos.y - mouseDowmPos.y;
    if ((diffX === 0 && diffY === 0)) return;
    // 坐标定位 = 上次定位 + 偏移量
    const offsetX = parseInt(`${diffX + offsetDis.left}`, 10);
    const offsetY = parseInt(`${diffY + offsetDis.top}`, 10);
    // 平移图片
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 清空画布
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    // 画出图片
    const { naturalWidth, height, naturalHeight } = imgElement;
    // 缩放比例
    const imgScale = height / naturalHeight;
    // 图片设定的宽度
    const width = naturalWidth * imgScale;
    ctx.drawImage(imgElement, offsetX, offsetY, width, height);
    // 更新鼠标按下的坐标
    setMouseDowmPos({
        x: pos.x,
        y: pos.y,
    });
    // 更新上次图片绘画的坐标
    setOffsetDis({
        left: offsetX,
        top: offsetY,
    })
};

handleMouseUp事件阻止执行onMouseMove事件,防止移动

const handleMouseUp = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault();
    const canvas = canvasRef.current as HTMLCanvasElement;
    canvas.style.cursor = 'default';
    setMouseDowmFlag(false);
};

图片缩放

canvas中图片缩放相对于canvas坐标的原点,缩放可以监听onWheel事件,事件对象event有一个记录滚轮滚动的属性deltaY,当向上滚动时deltaY<0,向下滚动时deltaY>0。缩放前后的坐标没有改变,只有图片大小发生变化。
image.png
image.png

每次缩放都需要记录下图片的大小,下次缩放时将以上次图片大小为基准进行缩放,下次平移时将绘画上次的图片大小

在初次绘画图片时记录下图片大小

/** 展示的图片大小 */
const [size, setSize] = useState<{width: number, height: number}>({width: WIDTH, height: HEIGHT});

/** 初始化图片位置 */
const initImg = () => {
  // coding...
  img.onload = () => {
    // coding...
    // 画出图片
    ctx.drawImage(img, left, top, width, height);
    // 记录下图片大小
    setSize({width, height});
    setImgElement(img);
  }
  // coding...
}

<canvas>绑定onWheel缩放后的坐标采用的是上次平移后的坐标,因为缩放后图片的坐标是不会变的,只有大小变了。offsetDis记录的是上次平移后的坐标,size记录的是缩放后的图片大小

  /** 滚动 */
  const handleWheelImage = (event: React.WheelEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    const canvas = canvasRef.current as HTMLCanvasElement;
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 向上为负,向下为正
    const bigger = event.deltaY > 0 ? -1 : 1;
    // 放大比例
    const enlargeRate = 1.2;
    // 缩小比例
    const shrinkRate = 0.8;
    // 缩放比例
    const { height: initHeight, naturalHeight, naturalWidth } = imgElement;
    const imgScale = initHeight / naturalHeight;
    
    const rate = bigger > 0 ? enlargeRate : shrinkRate;
    const width = size.width * rate;
    const height = size.height * rate;
 
    // 清空画布
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    ctx.drawImage(imgElement, offsetDis.left, offsetDis.top, width, height);
    // 记录下图片大小
    setSize({width, height});
    return false;
  };

图片缩放后,然后再平移,平移后的坐标 = 平移前的坐标 + 平移量,绘画的图片大小就不是固定的了,应该绘画上次缩放的图片大小,将handleMouseMove方法如下代码纠正一下。

const { naturalWidth, height, naturalHeight } = imgElement;
// 缩放比例
const imgScale = height / naturalHeight;
// 图片设定的宽度
const width = naturalWidth * imgScale;
ctx.drawImage(imgElement, offsetX, offsetY, width, height);

更改为:

ctx.drawImage(imgElement, offsetX, offsetY, size.width, size.height);

size记录的是上次缩放后的图片大小

现在图片缩放和平移就很完美了,我们再加一个功能,图号标注

图号标注

还记得初始定义的常量imgInfo吗?labelLeft、labelTop是需要被标注的图号的坐标,相对于原图左上角的定位

在初始化画布中画出标注,labelLeft、labelTop都要转化为相对于现图片的定位。

  /** 初始化图片位置 */
  const initImg = () => {
    /** 初始化一个图片节点对象 */
    const img: HTMLImageElement = new Image();
    img.onload = () => {
      // coding...
      // 画出图片
      ctx.drawImage(img, left, top, width, height);
      // 画图标
      const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale;
      const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale;
      ctx.strokeStyle = '#da2727';
      ctx.strokeRect(labelLeft, labelTop, 28, 28);
      
      setSize({width, height});
      setImgElement(img);
    }
    // coding...
  }

handleMouseMove中绘画标注,定位坐标 = 原定位坐标 * 初始图片比例 * 整体图片缩放比例 + 平移量

image.png

  const handleMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault();
    if (!mouseDowmFlag) return;
    const { clientX, clientY } = event;
    const canvas = canvasRef.current as HTMLCanvasElement;
    // 相对于canvas坐标
    const pos = windowToCanvas(canvas, clientX, clientY)
    // 偏移量
    const diffX = pos.x - mouseDowmPos.x;
    const diffY = pos.y - mouseDowmPos.y;
    if ((diffX === 0 && diffY === 0)) return;
    // 坐标定位 = 上次定位 + 偏移量
    const offsetX = parseInt(`${diffX + offsetDis.left}`, 10);
    const offsetY = parseInt(`${diffY + offsetDis.top}`, 10);
    // 平移图片
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 清空画布
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    // 画出图片
    ctx.drawImage(imgElement, offsetX, offsetY, size.width, size.height);
    // 画图标
    const { naturalWidth, height, naturalHeight } = imgElement;
    const imgScale = height / naturalHeight; // 缩放比例
    const totalZoomRateX = size.width / (naturalWidth * imgScale);
    const totalZoomRateY = size.height / height;
    const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale * totalZoomRateX + offsetX;
    const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale * totalZoomRateY + offsetY;
    ctx.strokeStyle = '#da2727';
    ctx.strokeRect(labelLeft, labelTop, 28 * totalZoomRateX, 28 * totalZoomRateY);

    // 更新按下的坐标
    setMouseDowmPos({
      x: pos.x,
      y: pos.y,
    });
    // 更新上次坐标
    setOffsetDis({
      left: offsetX,
      top: offsetY,
    })
  };

handleWheelImage方法中绘画出标注,放大后标注距离原点left = 初始标注距离图片的距离left * 整体放大比例 + 上次图片的坐标left
image.png

  /** 滚动 */
  const handleWheelImage = (event: React.WheelEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    const canvas = canvasRef.current as HTMLCanvasElement;
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 向上为负,向下为正
    const bigger = event.deltaY > 0 ? -1 : 1;
    // 放大比例
    const enlargeRate = 1.2;
    // 缩小比例
    const shrinkRate = 0.8;

    // 缩放比例
    const { height: initHeight, naturalHeight, naturalWidth } = imgElement;
    const imgScale = initHeight / naturalHeight;

    if (bigger > 0) {
      const width = size.width * enlargeRate;
      const height = size.height * enlargeRate;
      // 清空画布
      ctx.clearRect(0, 0, WIDTH, HEIGHT);
      ctx.drawImage(imgElement, offsetDis.left, offsetDis.top, width, height);

      // 画图标
      const totalZoomRateX = width / (naturalWidth * imgScale);
      const totalZoomRateY = height / initHeight;
      const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale * totalZoomRateX + offsetDis.left;
      const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale * totalZoomRateY + offsetDis.top;
      ctx.strokeStyle = '#da2727';
      ctx.strokeRect(labelLeft, labelTop, 28 * totalZoomRateX, 28 * totalZoomRateY);

      setSize({width, height});
    } else if (bigger < 0) {
      const width = size.width * shrinkRate;
      const height = size.height * shrinkRate;
      // 清空画布
      ctx.clearRect(0, 0, WIDTH, HEIGHT);
      ctx.drawImage(imgElement, offsetDis.left, offsetDis.top, width, height);

      // 画图标
      const totalZoomRateX = width / (naturalWidth * imgScale);
      const totalZoomRateY = height / initHeight;
      const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale * totalZoomRateX + offsetDis.left;
      const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale * totalZoomRateY + offsetDis.top;
      ctx.strokeStyle = '#da2727';
      ctx.strokeRect(labelLeft, labelTop, 28 * totalZoomRateX, 28 * totalZoomRateY);

      setSize({width, height});
    }
    return false;
  };

整体效果图:
part.gif

整体示例代码:

import React, { createRef, useEffect, useState } from 'react';

import styles from './index.module.scss';

/** canvas大小 */
const WIDTH = 466;
const HEIGHT = 326;

/** 接口返回的图片坐标是相对于原图左上角的定位 */
const imgInfo = {
  lableBottom: "492",
  lableLeft: "342",
  lableRight: "353",
  lableTop: "470",
  position: "03",
  src: 'https://parts-images.cassmall.com/bmw_test/322664.jpg?version=16',
}

const Canvas = () => {

  const canvasRef = createRef<HTMLCanvasElement>();
  /** 记录鼠标是否按下 */
  const [mouseDowmFlag, setMouseDowmFlag] = useState(false);
  /** 记录鼠标按下的坐标 */
  const [mouseDowmPos, setMouseDowmPos] = useState<{x: number, y: number}>({x: 0, y: 0});
  /** 记录这次平移之前的距离 */
  const [offsetDis, setOffsetDis] = useState<{left: number, top: number}>({left: 0, top: 0});
  /** 图片节点 */
  const [imgElement, setImgElement] = useState<HTMLImageElement>(new Image());
  /** 展示的图片大小 */
  const [size, setSize] = useState<{width: number, height: number}>({width: WIDTH, height: HEIGHT});

  
  /** 初始化图片位置 */
  const initImg = () => {
    /** 初始化一个图片节点对象 */
    const img: HTMLImageElement = new Image();
    img.onload = () => {
      const ctx = canvasRef.current?.getContext('2d') as CanvasRenderingContext2D;
      const { naturalWidth, height, naturalHeight } = img;
      // 缩放比例
      const imgScale = height / naturalHeight;
      // 图片设定的宽度
      const width = naturalWidth * imgScale;
      // 图片相对于父元素水平垂直居中的定位
      const left = WIDTH / 2 - width / 2;
      const top = HEIGHT / 2 - height / 2;
      // 画出图片
      ctx.drawImage(img, left, top, width, height);
      // 画图标
      const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale;
      const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale;
      ctx.strokeStyle = '#da2727';
      ctx.strokeRect(labelLeft, labelTop, 28, 28);
      
      setSize({width, height});
      setImgElement(img);
    }
    img.height = HEIGHT;
    img.src = imgInfo.src;
  }

  /** 将浏览器坐标系转化成canvas坐标系 */
  const windowToCanvas = (canvas: HTMLCanvasElement, x: number, y: number) => {
    var canvasBox = canvas.getBoundingClientRect();
    return {
        x: (x - canvasBox.left) * (canvas.width/canvasBox.width), // //对canvas元素大小与绘图表面大小不一致时进行缩放
        y: (y - canvasBox.top) * (canvas.height/canvasBox.height),
    };
  }

  /** 图片平移 */
  const handleMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault(); // 阻止浏览器默认行为,拖动会打开图片
    const { clientX, clientY } = event;
    // 相对于canvas坐标
    const canvas = canvasRef.current as HTMLCanvasElement;
    const pos = windowToCanvas(canvas, clientX, clientY);
    canvas.style.cursor = 'move';
    setMouseDowmFlag(true); // 控制只有在鼠标按下后才会执行mousemove
    setMouseDowmPos({
      x: pos.x,
      y: pos.y,
    });
  };

  const handleMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault();
    if (!mouseDowmFlag) return;
    const { clientX, clientY } = event;
    const canvas = canvasRef.current as HTMLCanvasElement;
    // 相对于canvas坐标
    const pos = windowToCanvas(canvas, clientX, clientY)
    // 偏移量
    const diffX = pos.x - mouseDowmPos.x;
    const diffY = pos.y - mouseDowmPos.y;
    if ((diffX === 0 && diffY === 0)) return;
    // 坐标定位 = 上次定位 + 偏移量
    const offsetX = parseInt(`${diffX + offsetDis.left}`, 10);
    const offsetY = parseInt(`${diffY + offsetDis.top}`, 10);
    // 平移图片
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 清空画布
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    // 画出图片
    ctx.drawImage(imgElement, offsetX, offsetY, size.width, size.height);
    // 画图标
    const { naturalWidth, height, naturalHeight } = imgElement;
    const imgScale = height / naturalHeight; // 缩放比例
    const totalZoomRateX = size.width / (naturalWidth * imgScale);
    const totalZoomRateY = size.height / height;
    const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale * totalZoomRateX + offsetX;
    const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale * totalZoomRateY + offsetY;
    ctx.strokeStyle = '#da2727';
    ctx.strokeRect(labelLeft, labelTop, 28 * totalZoomRateX, 28 * totalZoomRateY);

    // 更新按下的坐标
    setMouseDowmPos({
      x: pos.x,
      y: pos.y,
    });
    // 更新上次坐标
    setOffsetDis({
      left: offsetX,
      top: offsetY,
    })
  };

  const handleMouseUp = (event: React.MouseEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    event.preventDefault();
    const canvas = canvasRef.current as HTMLCanvasElement;
    canvas.style.cursor = 'default';
    setMouseDowmFlag(false);
  };

  /** 滚动 */
  const handleWheelImage = (event: React.WheelEvent<HTMLCanvasElement>) => {
    event.stopPropagation();
    const canvas = canvasRef.current as HTMLCanvasElement;
    const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 向上为负,向下为正
    const bigger = event.deltaY > 0 ? -1 : 1;
    // 放大比例
    const enlargeRate = 1.2;
    // 缩小比例
    const shrinkRate = 0.8;

    // 缩放比例
    const { height: initHeight, naturalHeight, naturalWidth } = imgElement;
    const imgScale = initHeight / naturalHeight;

    const rate = bigger > 0 ? enlargeRate : shrinkRate;
    const width = size.width * rate;
    const height = size.height * rate;
 
    // 清空画布
    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    ctx.drawImage(imgElement, offsetDis.left, offsetDis.top, width, height);

    // 画图标
    const totalZoomRateX = width / (naturalWidth * imgScale);
    const totalZoomRateY = height / initHeight;
    const labelLeft = parseInt(`${imgInfo.lableLeft}`, 10) * imgScale * totalZoomRateX + offsetDis.left;
    const labelTop = parseInt(`${imgInfo.lableTop}`, 10) * imgScale * totalZoomRateY + offsetDis.top;
    ctx.strokeStyle = '#da2727';
    ctx.strokeRect(labelLeft, labelTop, 28 * totalZoomRateX, 28 * totalZoomRateY);

    setSize({width, height});
    return false;
  };

  useEffect(() => {
    /** 初始化图片 */
    initImg();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className={styles.imgArea}>
      <canvas 
        ref={canvasRef} 
        width={WIDTH} 
        height={HEIGHT} 
        onWheel={handleWheelImage}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
      ></canvas>
    </div>
  )
}

export default Canvas;

记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。