纯js实现图片裁剪工具

5,334 阅读1分钟

图片裁剪工具

介绍

图片裁剪在前端交互中是很常见的功能,本文实现了通用的图片裁剪工具,可使用于react、vue、angular以及无框架的前端页面中。

动图演示

11.gif

使用

const imageCut = new ImageCut({
  scale: 2,
  listener: {
    onOk: (info) => {
      setImgInfo(info);
    },
  },
});
const img = document.querySelector('#img');
imageCut.showRect(img);

实现思路

  • 借助canvas可以绘图以及生成图片的能力
  • 先将图片先绘制到画布上
  • 然后操作鼠标选择要截取的矩形区域
  • 针对选中区域生成base64图片
  • 还可以选择复制或者下载

源码


import type { IRect, Point } from './interface';
import './index.less';

/**
 * 生成canvas
 * @param param0
 * @returns
 */
export function getCanvas({ width = 0, height = 0, scale = 1, attrs = {} as Record<string, any> }) {
  const canvas: any = document.createElement('canvas');
  Object.keys(attrs).forEach((key) => {
    const value = attrs[key];
    canvas.setAttribute(key, value);
  });
  canvas.setAttribute('width', `${width * scale}`);
  canvas.setAttribute('height', `${height * scale}`);
  canvas.style = `${attrs.style || ''};width: ${width}px;height: ${height}px;`;
  const ctx = canvas.getContext('2d');
  ctx?.scale(scale, scale);
  return { canvas, ctx };
}

/**
 * 将base64下载为文件
 * @param data base64数据
 * @param filename 文件名
 */
export function downloadBase64File(dataUrl: string, filename: string) {
  const data = base64Img2Blob(dataUrl);
  window.URL = window.URL || window.webkitURL;
  const urlBlob = window.URL.createObjectURL(data);
  const link = document.createElement('a');
  link.style.display = 'none';
  link.href = urlBlob;
  const downloadFileName = filename;
  link.setAttribute('download', downloadFileName);
  document.body.appendChild(link);
  link.click();
  function base64Img2Blob(code: string) {
    const parts = code.split(';base64,');
    const contentType = parts[0].split(':')[1];
    const raw = window.atob(parts[1]);
    const rawLength = raw.length;

    const uInt8Array = new Uint8Array(rawLength);
    for (let i = 0; i < rawLength; ++i) {
      uInt8Array[i] = raw.charCodeAt(i);
    }
    return new Blob([uInt8Array], { type: contentType });
  }
}

/**
 * 拷贝字符串到剪切板
 * @param value 字符串
 */
export function copyStr(value: string) {
  console.log('copyStr', value);
  const transfer = document.createElement('input');
  document.body.appendChild(transfer);
  transfer.value = value;
  transfer.setSelectionRange(0, 999999999);
  transfer.focus();
  transfer.select();
  if (document.execCommand('copy')) {
    document.execCommand('copy');
  }
  transfer.blur();
  document.body.removeChild(transfer);
}


/**
 * 加载base64图片
 * @param base64
 * @returns
 */
export const loadImage = (base64: string) => {
  return new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = '';
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
    img.src = base64;
  });
};

const resetRect = () => {
  return { x: 0, y: 0, width: 0, height: 0 } as IRect;
};

interface IImageInfo {
  base64: string;
  width: number;
  height: number;
}

interface IListener {
  onOk?: (params: IImageInfo) => void;
}

interface IProps {
  scale?: number;
  listener?: IListener;
}

class ImageCut {
  scale = 1;
  canvas: HTMLCanvasElement;
  ctx: any;
  pos = '';
  initPoint: Point = { x: 0, y: 0 };
  rect: IRect = resetRect();
  initRect: IRect = resetRect();
  originRect = resetRect();
  img: any = null;
  listener: IListener = {};
  constructor(props: IProps = {}) {
    const { scale = 1 } = props;
    const { canvas, ctx } = getCanvas({ width: 0, height: 0 });
    this.canvas = canvas;
    this.ctx = ctx;
    this.scale = scale;
    this.listener = props.listener || {};
  }
  showRect(img: HTMLImageElement) {
    this.reset();
    const rect = img.getBoundingClientRect();
    const rectContainer = this.renderRect(rect);
    document.body.appendChild(rectContainer);
    this.img = img;
  }
  getCutImageInfo() {
    this.drawImage(this.img);
    const { originRect, rect } = this;
    const info = {
      x: rect.x - originRect.x,
      y: rect.y - originRect.y,
      width: rect.width,
      height: rect.height,
    };
    const base64 = this.cutImage(info);
    return { base64, width: rect.width, height: rect.height };
  }
  startCut() {
    const { listener } = this;
    const res = this.getCutImageInfo();
    this.reset();
    listener.onOk?.(res);
  }
  reset() {
    this.pos = '';
    this.initPoint = { x: 0, y: 0 };
    this.rect = resetRect();
    this.initRect = resetRect();
    this.originRect = resetRect();
    this.removeRect();
  }
  drawImage(img: HTMLImageElement) {
    const { ctx, scale } = this;
    const { width, height } = img;
    this.setCanvasSize(width, height);
    ctx.drawImage(img, 0, 0, width * scale, height * scale);
  }
  cutImage(rect: IRect) {
    console.log(rect);
    const { scale, ctx, canvas } = this;
    const { x, y, width, height } = rect;
    const imgData = ctx.getImageData(x * scale, y * scale, width * scale, height * scale);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    this.setCanvasSize(width, height);
    ctx.putImageData(imgData, 0, 0);
    const base64 = canvas.toDataURL('image/png');
    return base64;
  }

  setCanvasSize(width: number, height: number) {
    const { canvas, scale } = this;
    canvas.setAttribute('width', `${width * scale}`);
    canvas.setAttribute('height', `${height * scale}`);
    canvas.setAttribute('style', `width: ${width}px;height: ${height}px;`);
  }
  renderRect(rect: IRect) {
    const { x, y, width, height } = rect;
    this.rect = { x, y, width, height };
    this.originRect = { x, y, width, height };
    const container: any = document.createElement('div');
    container.setAttribute('id', `rect__container`);
    container.style = `
      position: fixed;
      border: 2px solid red;
      left: ${rect.x}px;
      top: ${rect.y}px;
      width: ${rect.width}px;
      height: ${rect.height}px;
    `;
    const rectList = [
      { x: 50, y: 0, pos: 'top', mousePos: 'ns' },
      { x: 50, y: 100, pos: 'bottom', mousePos: 'ns' },
      { x: 0, y: 50, pos: 'left', mousePos: 'ew' },
      { x: 100, y: 50, pos: 'right', mousePos: 'ew' },
      { x: 100, y: 0, pos: 'right-top', mousePos: 'nesw' },
      { x: 100, y: 100, pos: 'right-bottom', mousePos: 'nwse' },
      { x: 0, y: 100, pos: 'left-bottom', mousePos: 'nesw' },
      { x: 0, y: 0, pos: 'left-top', mousePos: 'nwse' },
    ];
    rectList.forEach((e: any, i: number) => {
      const rectDom: any = document.createElement('div');
      rectDom.setAttribute('data-pos', e.pos);
      rectDom.setAttribute('data-index', i);
      rectDom.setAttribute('id', `rect__${e.pos}`);
      rectDom.style = `
        position: absolute;
        left: ${e.x}%;
        top: ${e.y}%;
        width: 7px;
        height: 7px;
        border: 1px solid #ddd;
        background-color: #fff;
        transform: translate(-50%, -50%);
        cursor: ${e.mousePos}-resize;
      `;
      rectDom.addEventListener('mousedown', this.handleMouseDown);
      container.appendChild(rectDom);
    });
    const confirmIcon = `
      <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="18" height="18">
        <path
          d="M159.405 462.713l218.634 218.634L859.54 196.055l94.784 94.151-580.076 583.236L69.676 568.87l89.729-106.157z"
          fill="currentColor"
        ></path>
      </svg>
    `;
    const cancelIcon = `
      <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
        <path
          d="M896 812.9 594.2 511.1l301.2-301.2-82.3-82.3L511.9 428.8l-301-301L128.6 210.1l301 301L128 812.7l82.3 82.3 301.6-301.6 301.8 301.8L896 812.9zM896 812.9"
          fill="currentColor"
        ></path>
      </svg>
    `;

    const downloadIcon = `
      <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
        <path
          d="M896 864.384v96H128v-96h768zM564.096 64v506.176l201.376-201.44 67.84 67.84-316.768 316.8-316.8-316.8 67.904-67.84 200.448 200.416V64h96z"
          fill="currentColor"
        ></path>
      </svg>
    `;

    const copyIcon = `
      <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="15" height="15">
        <path
          d="M921.6 819.2h-102.4v102.4c0 56.32-46.08 102.4-102.4 102.4H102.4c-56.32 0-102.4-46.08-102.4-102.4V307.2c0-56.32 46.08-102.4 102.4-102.4h102.4V102.4c0-56.32 46.08-102.4 102.4-102.4h614.4c56.32 0 102.4 46.08 102.4 102.4v614.4c0 56.32-46.08 102.4-102.4 102.4zM153.6 307.2c-30.72 0-51.2 20.48-51.2 51.2v512c0 30.72 20.48 51.2 51.2 51.2h512c30.72 0 51.2-20.48 51.2-51.2V358.4c0-30.72-20.48-51.2-51.2-51.2H153.6z m768-153.6c0-30.72-20.48-51.2-51.2-51.2H358.4c-30.72 0-51.2 20.48-51.2 51.2v51.2h409.6c56.32 0 102.4 46.08 102.4 102.4v409.6h51.2c30.72 0 51.2-20.48 51.2-51.2V153.6z"
          fill="currentColor"
        ></path>
      </svg>
    `;

    const operationDom: any = document.createElement('div');
    operationDom.style = `
      position: absolute;
      right: 0;
      bottom: -30px;
      width: 120px;
      height: 26px;
      border: 1px solid #ddd;
      border-radius: 2px;
      background-color: #fff;
      display: flex;
      justify-content: center;
      padding: 0 4px;
    `;

    const downloadDom: any = document.createElement('div');
    downloadDom.setAttribute('title', '下载');
    downloadDom.setAttribute('class', 'image-cut-operation-icon');
    downloadDom.innerHTML = downloadIcon;
    downloadDom.addEventListener('click', () => {
      const { base64 } = this.getCutImageInfo();
      downloadBase64File(base64, `${Date.now()}.png`);
    });
    operationDom.appendChild(downloadDom);


    const copyDom: any = document.createElement('div');
    copyDom.setAttribute('title', '复制到剪切板');
    copyDom.setAttribute('class', 'image-cut-operation-icon');
    copyDom.innerHTML = copyIcon;
    copyDom.addEventListener('click', () => {
      const { base64 } = this.getCutImageInfo();
      copyStr(base64);
    });
    operationDom.appendChild(copyDom);

    const cancelDom: any = document.createElement('div');
    cancelDom.setAttribute('title', '取消');
    cancelDom.setAttribute('class', 'image-cut-operation-icon');
    cancelDom.innerHTML = cancelIcon;
    cancelDom.addEventListener('click', () => this.reset());
    operationDom.appendChild(cancelDom);

    const confirmDom: any = document.createElement('div');
    confirmDom.setAttribute('title', '确认');
    confirmDom.setAttribute('class', 'image-cut-operation-icon');
    confirmDom.innerHTML = confirmIcon;
    confirmDom.addEventListener('click', () => this.startCut());
    operationDom.appendChild(confirmDom);





    container.appendChild(operationDom);
    return container;
  }

  updateRect() {
    const { rect } = this;
    const container = document.getElementById('rect__container');
    if (!container) {
      return;
    }
    container.style.left = `${rect.x}px`;
    container.style.top = `${rect.y}px`;
    container.style.width = `${rect.width}px`;
    container.style.height = `${rect.height}px`;
  }

  removeRect() {
    const container = document.getElementById('rect__container');
    container?.parentNode?.removeChild(container);
  }

  handleMouseDown = (e: any) => {
    const { target, pageX: x, pageY: y } = e;
    const pos = target.getAttribute('data-pos');
    this.pos = pos;
    this.initPoint = { x, y };
    this.initRect = { ...this.rect };
    document.addEventListener('mousemove', this.handleMouseMove);
    document.addEventListener('mouseup', this.handleMouseUp);
  };
  handleMouseMove = (e: any) => {
    if (!this.pos) {
      return;
    }
    const { initRect } = this;
    const { x, y } = this.initPoint;
    const { pageX, pageY } = e;
    let dx = 0;
    let dy = 0;
    let dw = 0;
    let dh = 0;
    if (this.pos.includes('top')) {
      dy = pageY - y;
      dh = -dy;
    }
    if (this.pos.includes('bottom')) {
      dh = pageY - y;
    }
    if (this.pos.includes('left')) {
      dx = pageX - x;
      dw = -dx;
    }
    if (this.pos.includes('right')) {
      dw = pageX - x;
    }
    dx = Math.min(initRect.width, Math.max(0, dx));
    dy = Math.min(initRect.height, Math.max(0, dy));
    dw = Math.min(0, Math.max(-initRect.width, dw));
    dh = Math.min(0, Math.max(-initRect.height, dh));
    this.rect = {
      x: initRect.x + dx,
      y: initRect.y + dy,
      width: initRect.width + dw,
      height: initRect.height + dh,
    };
    this.updateRect();
  };
  handleMouseUp = () => {
    if (!this.pos) {
      return;
    }
    this.pos = '';
    this.initPoint = { x: 0, y: 0 };
    document.removeEventListener('mousemove', this.handleMouseMove);
    document.removeEventListener('mouseup', this.handleMouseUp);
  };
}

export default ImageCut;

  • 样式
.image-cut-operation-icon {
  color: #555;
  cursor: pointer;
  margin-left: 8px;
  display: flex;
  align-items: center;
  &:hover {
    color: #1890ff;
  }
}