前言

近段时间和组内的小伙伴共同负责开发了一个名为物料智能设计平台的项目,该项目的主要功能是利用可视化拖拽技术搭建并编辑页面,最终生成海报图,供UI等设计同事使用,提高人员效率。

由于人员和精力问题,我们仅做了初版来解决图片海报的生成功能,后续我们将扩展更多功能来丰富和解决前端甚至更多部门的效率提升问题。

本期我们主要是针对项目开发中一些功能的实现做讲解以及分析。

下面先来几张项目截图:

image.png
materials.gif

一些技术要点分析

以下是该项目中一些比较重要的功能:

  • 画布智能缩放
  • 解析PSD文件
  • 画布转换为图片并下载
  • 解析蒙版图层
  • 点击匹配画布距离最近图层

画布智能缩放

画布缩放这个功能其实就是根据监听浏览器宽高的变化以及画布的宽高,自动调节画布区域的缩放等级,保证画布区域能够始终显示在可视区域内,这个功能能够大大提高笔记本电脑用户的用户体验。

效果图

materials2.gif
materials3.gif

原理

在讲解之前,先明确两个概念:

1.中间区域

2.画布区域

如下图:

红色边框的代表中间区域,绿色边框代表画布区域

微信图片编辑_20211229172331.jpg

首先,我们需要全局定义一个名为 globalZoom的字段,它代表的是画布区域的缩放等级,画布最终展现到页面的尺寸就是经过这个 globalZoom计算得来的。

所以,我们只需要动态计算出来一个合适的 globalZoom缩放等级就可以了。

代码如下:

type rectInfo = {
    width: number
    height: number
}
/**
 * 获取画布的合适缩放比例
 * @param centerRect  中间区域的尺寸信息
 * @param editorRect  实际画布的尺寸信息
 * @returns {number} 缩放比例 
 */
export const getEditorSuitScale = (centerRect: rectInfo, editorRect: rectInfo): number => {
    const { width, height } = editorRect;
    //如果画布的宽度、高度都大于中间区域的宽度、高度
    const aspectRatio = width / height;
    //如果画布的实际尺寸都小于中间区域的宽度、高度  就不需要缩放  则缩放等级为100
    if (width < centerRect.width && height < centerRect.height) return 100;
    let curScale = 1;
    if (aspectRatio >= 1) {
        //如果宽高比大于等于1 则画布宽度先以中间区域宽度的90%进行展示 由此得出来一个缩放比例
        curScale = (centerRect.width * 0.9) / width;
        //如果画布缩放后的高度仍然大于中间区域的高度 则画布高度已中间区域高度的90%展示 由此得出来一个缩放比例
        if (centerRect.height <= height * curScale) {
            curScale = (centerRect.height * 0.9) / height;
        }
    } else {
        //原理同上
        curScale = (centerRect.height * 0.9) / height;
        if (centerRect.width <= width * curScale) {
            curScale = (centerRect.width * 0.9) / width;
        }
    }
    return Math.floor(curScale * 100)
}

然后监听窗口的 resize事件,动态计算缩放等级即可。 resize事件不要忘了做防抖,防止频繁调用。

解析PSD文件

解析PSD文件,其核心原理是利用开源库psd.js进行解析的,将解析出来的结构转换为我们组件所需要的结构即可。 这里边的坑挺深的,这里就不细讲了,等彻底研究明白之后重新写个文章细说。

效果图

materials4.gif

画布转换为图片并下载

画布转为图片我直接使用的是html2canvas 开源库,传入当前画布DOM即可得到url为base64的图片路径,更多用法详情请看html2canvas官方文档

大致代码如下:

<template>
   <div id="canvas" ref="canvas">画布内容</div>
</template>
<script>
  import html2canvas from "html2canvas";
  export default {
   ....

     methods:{  
      /**
       * 导出图片
       * @param {String} type 图片类型
       * @param {Number} quality 图片质量  
       * @return {String} base64 图片地址
       */
       getImageUrl(type = "png", quality = 1){
           let imgType = `image/${type}`;
           return html2canvas(this.$refs.canvas, {
              allowTaint: true,//允许跨源图像污染画布
              useCORS: true,//允许跨域获取图片
           }).then(canvas=>(canvas.toDataURL(imgType, quality)))
        }
     }
   ....

   }

</script>

拿到base64图片地址后,我们需要将base64图片数据转换为 Blob对象。

代码如下:

/**
 * @method 获取Blob文件
 * @param {String} url 文件地址 base64地址或线上链接  
 * @param {String} text 返回的数据类型 blob text json
 * @returns 
 */
const getBlob = (url: string, type?: "blob" | "text" | "json"): Promise<Blob> => {
    return new Promise((resolve) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        if (!type) type = 'blob';
        xhr.responseType = type;
        xhr.onload = () => {
            if (xhr.status == 200) {
                resolve(xhr.response)
            }
        }
        xhr.send();
    })
}
//获取blob对象
let blob = await  getBlob(base64);

获取到 Blob对象后,还需要将它转换为 Blob URL

Blob URL 是blob 协议的 URL,它的格式为:blob:http://xxx ,常用作文件的下载地址以及图片、音频资源地址。

Blob URL可以通过 window.createObjectURL方法创建,它接受一个 FileBlob或者 MediaSource对象,详情可查看MDN文档

创建Blob URL

let blobUrl = window.createObjectURL(blob);

利用 a标签的 download属性来进行下载文件:

/**
 * @param blob Blob URL
 * @param fileName 文件名
 */
const downLoadFile = (blob: Blob, fileName: string) => {
    const link = document.createElement('a');
    const body = document.querySelector('body');
    link.href = window.URL.createObjectURL(blob);
    link.download = fileName;
    link.style.display = 'none';
    body!.appendChild(link);
    link.click();
    body!.removeChild(link);
     //下载完成后 释放当前的Blol URL 
    window.URL.revokeObjectURL(link.href);
}
//下载文件
downLoadFile(blobUrl);

至此,画布转换为图片并支持下载的功能就完成了。

解析蒙版图层

当PSD源文件中有使用蒙版图层的时候,由于其并不是一张图片构成的,所以需要我们特殊处理一下。

解析出来的蒙版图层组件结构信息如下:

{
  component:"v-mask",//蒙版组件的组件名称
  propValue:{
    mask:"https://test-oscarmx.oss-cn-beijing.aliyuncs.com/eic/uu-design/20211215/images/TSBeP4jSaF.png", //蒙版图片地址
    url:"https://test-oscarmx.oss-cn-beijing.aliyuncs.com/eic/uu-design/20211215/images/BHh7DrPK3R.png" // 原图地址
  }, //组件参数
  style:{},//组件样式参数
   ....
}

其中最重要的两个参数就是 propValue中的 mask以及 url字段,
查看这两个字段所对应的图片,如下:
mask:

image.png

url:

image.png

而效果图上的样子是这样:

image.png

所以,最终的图片是由 mask以及 url组合出来的。

此时,我们可以利用 canvas的 globalCompositeOperation属性来实现。

MDN官方文档来看,我们可以知道当值为 source-in的时候,可以使新图层只在已存在的图层区域上显示,所以我们可以利用这个特性来达到裁剪的效果。
image.png

大致代码如下:

/**
 * 加载所有图片
 * @param urls 
 * @returns 
 */
const loadAllImages = (urls: Array<string>):Promise<Array<HTMLImageElement>> => {
    return new Promise(resolve=>{
        let index = 0;
        let imageArr:Array<HTMLImageElement>= [];
        urls.forEach((url:string)=>{
            let img = new Image();
            img.src = url;
            img.crossOrigin = "anonymous";
            img.onload = ()=>{
                index++;
                imageArr.push(img);
                if(index === urls.length){
                    resolve(imageArr);
                }
            }
        })
    })
}
//生成对应蒙层的图片
const createMaskImageUrl = async () => {
  let canvas = document.createElement('canvas');
  //加载蒙层以及原图图片
  let images: Array<HTMLImageElement> = await loadAllImages([mask, url]);
  canvas.width = width;
  canvas.height = height;
  let ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, width, height);
  ctx.save();
  //先绘制蒙层图片
  ctx.drawImage(images[0], 0, 0, width, height);
  ctx.restore();
  ctx.save();
  //设置画笔的globalCompositeOperation属性为source-in
  ctx.globalCompositeOperation = "source-in";
  //最后绘制原图 达到裁剪的效果
  ctx.drawImage(images[1], 0, 0, width, height);
  ctx.restore();
};

由此,我们就支持了PSD文件中蒙版图层的解析以及显示。

点击匹配画布最近图层

下面请先看效果图:

2021-12-29 23.52.21.gif

我们可以看到明明人物的图层是处于最顶层,文字层级是在人物层级之下的,但是我们去点击文字的区域时,仍然能够选中文字图层,而没有被处于最顶层的人物图层所遮挡,这里也算是一个能够提升用户体验的功能了。如果没有这个功能,试想一下当你想去修改处于人物层级下的文字时,还需要把人物的图层给移动到别的地方,然后再点击文本进行修改,修改完成后再把人物图层移动到原来的位置,如果图层少还可以接受,但如果是一个比较复杂的设计图,这样来回修改效率是非常低的。所以这个功能还是很有用的。

那么,这个功能是如何实现的呢?

我们都知道 两点之间,线段最短。 所以如果我们能够拿到当前鼠标点击坐标与每个组件中心点坐标之间距离的话,是不是就能拿到距离最短的那个组件。

以下是简单的代码实现:

<template>
  <!-- 画布容器-->
  <div id="editor-shell" @click.stop="handleCanvasClick">...</div>
</template>
<script>
export default defineComponent({
  data() {
    return {
      componentData: [], //组件数据
    };
  },
  methods: {
    handleCanvasClick(e:MouseEvent) {
      let component = this.getNearComponent(e);
      console.log("当前应选中的组件", component);
    },
    //获取离当前鼠标点击的位置最近的组件
    getNearComponent(e: MouseEvent) {
      //获取当前鼠标点击区域的x,y坐标
      const { clientX, clientY } = e;
      //获取画布区域的x,y坐标
      const { x, y } = this.editor.getBoundingClientRect();
      //计算当前鼠标点击点相对于画布区域的x坐标
      let curRectX = clientX - x;
      //计算当前鼠标点击点相对于画布区域的y坐标
      let curRectY = clientY - y;
      let intersectionComponent = this.componentData
        .filter((item: ComponentType, index: number) => {
        //获取每个组件的宽高以及left、top
          const { left, top, width, height } = item.style;
          item.index = index;
          //利用勾股定理计算出来每个组件中心点距离鼠标点击点的距离
          item.distance = Math.sqrt(
            Math.pow(Math.abs(curRectX -(left+width/2)), 2) +
              Math.pow(Math.abs(curRectY -(top+height/2)), 2)
          );
         //只返回鼠标点击点处于组件区域内的组件
          return (
            curRectX >= left &&
            curRectX <= left + width &&
            curRectY >= top &&
            curRectY <= top + height
          );
        })
        .sort((a: ComponentType, b: ComponentType) => a.distance - b.distance); //按距离进行排序
      if (intersectionComponent.length) {
        //取两点之前距离最近的组件
        let nearComponent = intersectionComponent[0];
        return nearComponent;
      }
    },
  },
});
</script>

总结

以上就是本项目中一些技术点的分析和总结,当然这只是一部分,项目中还有许多这样的技术点需要总结归纳以及优化,这次的文章就先讲这么多啦。

参考文章

前端实现文件下载
学习canvas 的 globalCompositeOperation 做出的神奇效果
MDN- globalCompositeOperation
JS File以及Blob详解

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注