前言

公众号:【可乐前端】,期待关注交换,分享一些有意思的前端常识

这是一个针对于图像编辑的系列,我会陆陆续续实现包含但不限于:图像滤镜、高级滤镜、图像卷积、图像压缩、水印、Gif操作、图像格式转换等性能。尽量所有的计算都在前端(浏览器)实现,不波及到服务器计算。

其实很多时候让服务器去操作文件会更简略一些,但咱们还是致力不依附服务器,看看能不能实现一个纯前端的图像编辑器!如果你感觉这样的内容有意思的话,点点关注点点赞吧~

体验地址

基础架构

上面咱们先来看整个编辑器的宏观架构,图像编辑器我应用的技术栈是React+Vite+Mobx+antd,这也是本人比拟习惯的技术栈,但其实外围并不在这些框架外面,比方明天实现的操作外围是在Canvas,所以这跟你用什么框架关系不大,感兴趣的话能够急躁看上来。

页面设计

整个页面在没有上传图片的时候,只有一个上传框。

在上传完图片之后,会有大抵三个外围的区域:

  • 左侧图像操作区域
  • 两头图片预览区域
  • 右侧的缩略图区域

代码设计

按照下面的交互设计,咱们就能够来实现页面的组件分层,组件的关系大抵如下图:

在划分好组件的职责之后,就要开始更形象的去划分整一个编辑器的构造。首先整体的交互是:

  • 点击上传图片开始预览
  • 左侧的操作会影响两头区域的图片预览成果
  • 右侧的图片抉择区域能够自由选择以后须要编辑和预览的图片
  • 能够下载编辑后的图片

这样看来跨组件的通信会相对来说比拟多,所以整个编辑器采纳了Mobx作为状态管理工具。

这里的左侧操作区域跟右侧图像列表区域都会对Mobx的数据产生影响,比如说对以后抉择的图像利用滤镜成果;更换以后抉择的图像等等,在这些数据变更之后,预览区通过监听Mobx的数据变更,来执行相应的UI更新渲染。

那么先来关注一下Mobx里存储了什么货色:

  • FileStore

    • files:上传的图像列表
    • currentFile:以后选中的图像
    • actionMap:图像id对应的操作

      • type:操作类型,比方FILTER滤镜
      • 其余属性

骨架搭建

依据下面的设计,能够先写出如下的架子:

const Home = () => {  return (    <Layout>      <div className={styles.container}>        <Tools />        <Content />        <FileList />      </div>    </Layout>  );};export default Home;

左侧操作区

其中Tools的交互依赖了antdMenu组件,这里我略微批改了一下菜单组件,把具体的图像操作放在了具体的下拉菜单中:

那Tools就能够分解成一个个具体的操作空间,具体的实现代码如下:

import { Menu } from "antd";import styles from "./index.module.less";import Filter from "./Filter";import { observer } from "mobx-react-lite";import useStore from "../../store/RootStore";const Tools = () => {  const { fileStore } = useStore();  const { currentFile } = fileStore;  const items = [    {      key: "0",      label: "根底滤镜",      children: [        {          key: "0-0",          label: <Filter />,        },      ],    },  ];  if (fileStore.files.length === 0) {    return;  }  return (    <div className={styles.container}>      <Menu        key={currentFile?.uid}        className={styles.toolMenu}        mode="inline"        items={items}      ></Menu>    </div>  );};export default observer(Tools);

items数组就是所有的操作汇合,具体每一个操作外面的内容则由具体的组件去管制。

两头预览区

两头区域则是图像的预览区域,咱们是须要实现各种各样的图像成果,应用img标签来渲染显然是不太正当的,而canvas就是一个适合的抉择。那么这里就能够实现一个预览组件如下:

  const init = (file) => {    const reader = new FileReader();    reader.readAsDataURL(file);    reader.onload = (readerEvent) => {      const url = readerEvent.target.result;      const image = new Image();      image.src = url;      dataUrl.current = url;      image.onload = () => {        const canvas = displayCanvas.current;        const context = canvas.getContext("2d");        const originalWidth = image.width;        const originalHeight = image.height;        const defaultWidth =          container.current.getBoundingClientRect().width * 0.8;        canvas.width = originalWidth;        canvas.height = originalHeight;        context.clearRect(0, 0, canvas.width, canvas.height);        context.drawImage(image, 0, 0, canvas.width, canvas.height);      };  };  useEffect(() => {    init(file);  }, [file]);

解释一下下面的代码:

  • file是一个File对象,就是咱们通过Upload组件上传文件获取到的内容
  • 读出file的内容,并创立一个Image对象去加载
  • 将加载好的图像绘制到canvas

右侧图像列表

右侧的图像列表就是获取Mobx的所有图像渲染进去做一个预览,对于每一个图像来说还有删除跟下载的逻辑。具体的代码实现如下:

import { observer } from "mobx-react-lite";import useStore from "../../store/RootStore";import styles from "./index.module.less";import { DownloadOutlined, DeleteOutlined } from "@ant-design/icons";import React, { useEffect, useRef, useState } from "react";import Upload from "../Upload";import { toJS } from "mobx";import download from "../../actions/download";const FileList = () => {  const { fileStore } = useStore();  const { files, currentFile, actionMap } = fileStore;  const [imageUrls, setImageUrls] = useState([]);  const urlRef = useRef([]);  useEffect(() => {    urlRef.current.forEach((url) => URL.revokeObjectURL(url));    const urls = toJS(files).map((file) => URL.createObjectURL(file));    urlRef.current = urls;    setImageUrls(urls);  }, [files]);  if (files.length === 0) {    return;  }  return (    <div className={styles.container}>      <div className={styles.scrollWrapper}>        {imageUrls.map((url, index) => (          <div            key={url}            onClick={() => fileStore.setCurrentFile(files[index])}            className={`${styles.imgContainer} ${              files?.[index]?.uid === currentFile?.uid                ? styles.imgContainerSelected                : ""            }`}          >            <img              className={styles.img}              key={index}              src={url}              alt={`Image ${index}`}            />            <div className={styles.actions}>              <DownloadOutlined                onClick={() => {                  const file = files[index];                  download(files[index], actionMap[file.uid]);                }}              />              <DeleteOutlined                onClick={() => {                  const file = files[index];                  fileStore.deleteFile(file.uid);                }}              />            </div>          </div>        ))}      </div>      <div className={styles.upload}>        <Upload inline />      </div>    </div>  );};export default observer(FileList);

离屏Canvas

在搭建好下面的架子之后,咱们来思考一个问题。如果我有一张2000*2000像素的图片,依照下面的代码来预览,在预览区域中,咱们的canvas大小是多少呢?是的,也是2000*2000,因为咱们应用了图片的原始宽度跟原始高度作为canvas的宽高。那其实这样是不太正当的,因为整一个预览区域的宽度是无限的,咱们必须对画布进行一些缩放。

此时如果我创立一个500*500的画布,而后将这张图片绘制到这个画布上会有什么问题吗?就预览来说,是没有问题的,宽高比也一样,看起来可能会略微含糊一点,但问题不大。然而当咱们从新再把这张图片下载下来的时候,会发现图片的像素变低了,其实咱们无心中就做了一个有损的图片压缩操作。

那咱们既想预览图片的时候以一个正当的宽高去预览,又不想导出的时候影响图像的品质,这里就须要引入一个离屏canvas

离屏 Canvas 指的是在浏览器中创立一个不间接显示在页面上的 Canvas 元素。这种 Canvas 元素通常用于进行一些图形计算、绘制或解决,而无需在用户界面中显示。离屏 Canvas 提供了一种在不烦扰用户界面的状况下进行图形操作的形式。

也就是说咱们的预览区域会有两个canvas

  • displayCanvas:显示在界面上的canvas,宽高按肯定比例缩放
  • memoryCanvas:在内存的canvas,宽高与原图像保持一致

搞清楚这一点之后,咱们能够从新写一下绘制的初始化代码:

import { useEffect, useRef, useState } from "react";import styles from "./index.module.less";import useFilter from "../../hooks/useFilter";import { observer } from "mobx-react-lite";import useStore from "../../store/RootStore";const Preview = ({ file }) => {  const memoryCanvas = useRef(null);  const displayCanvas = useRef(null);  const container = useRef(null);  const { fileStore } = useStore();  const currentImg = useRef(null);  const init = (file) => {    const reader = new FileReader();    reader.readAsDataURL(file);    reader.onload = (readerEvent) => {      const url = readerEvent.target.result;      const image = new Image();      image.src = url;      image.onload = () => {        initMemoryCanvas();        initDisplayCanvas();        currentImg.current = image;      };      const initMemoryCanvas = () => {        const originalWidth = image.width;        const originalHeight = image.height;        const canvas = document.createElement("canvas");        memoryCanvas.current = canvas;        const context = canvas.getContext("2d");        canvas.width = originalWidth;        canvas.height = originalHeight;        context.clearRect(0, 0, canvas.width, canvas.height);        context.drawImage(image, 0, 0, originalWidth, originalHeight);      };      const initDisplayCanvas = () => {        const canvas = displayCanvas.current;        const context = canvas.getContext("2d");        const originalWidth = image.width;        const originalHeight = image.height;        const defaultWidth =          container.current.getBoundingClientRect().width * 0.8;        canvas.width = Math.min(defaultWidth, originalWidth);        canvas.height = Math.min(          defaultWidth * Number((originalWidth / originalHeight).toFixed(2)),          originalHeight        );        context.clearRect(0, 0, canvas.width, canvas.height);        context.drawImage(image, 0, 0, canvas.width, canvas.height);      };      initMemoryCanvas();      initDisplayCanvas();    };  };  useEffect(() => {          init(file);  }, [file]);  return (    <div className={styles.container} ref={container}>      <canvas ref={displayCanvas}></canvas>    </div>  );};export default observer(Preview);

滤镜

明天咱们介绍的canvas滤镜有如下几种:

  • 灰度(grayscale):

    • 形容:将图像转为灰度。
    • 取值范畴:0(原始色彩)到 100(齐全灰度)。
    • 默认值:0。
  • 含糊(blur):

    • 形容:使图像含糊。
    • 取值范畴:0(无含糊)以上的正值,示意含糊水平。
    • 默认值:0。
  • 色相旋转(hue-rotate):

    • 形容:依照肯定的角度旋转图像的色相。
    • 取值范畴:0deg(原始色彩)到 360deg(残缺的色彩轮旋转)。
    • 默认值:0。
  • 对比度(contrast):

    • 形容:调整图像的对比度。
    • 取值范畴:0(齐全灰度)到 200(最大对比度)。
    • 默认值:100。
  • 反转色彩(invert):

    • 形容:反转图像的色彩。
    • 取值范畴:0(原始色彩)到 100(齐全反转)。
    • 默认值:0。
  • 饱和度(saturate):

    • 形容:调整图像的饱和度。
    • 取值范畴:0%(齐全灰度)以上的正值,示意饱和度的倍数。
    • 默认值:100。
  • 亮度(brightness):

    • 形容:调整图像的亮度。
    • 取值范畴:0%(齐全光明)以上的正值,示意亮度的倍数。
    • 默认值:100。

滤镜的UI实现是一个Form表单,具体代码如下:

import { Button, Form, Slider } from "antd";import { observer } from "mobx-react-lite";import useStore from "../../../store/RootStore";import { isEmpty } from "lodash";import { ACTION_TYPE } from "../../../utils/constants";import { toJS } from "mobx";const DEFAULT_VALUE = {  grayscale: 0,  blur: 0,  "hue-rotate": 0,  contrast: 100,  invert: 0,  saturate: 100,  brightness: 100,};const Filter = () => {  const [form] = Form.useForm();  const { fileStore } = useStore();  const { currentFile, updateActionMap, actionMap, updateFile } = fileStore;  const handleValueChange = (_, values) => {    if (currentFile?.uid) {      updateActionMap(currentFile.uid, { ...values, type: ACTION_TYPE.FILTER });    }  };  const filter = actionMap?.[currentFile?.uid] || {};  return (    <div>      <Form        initialValues={!isEmpty(toJS(filter)) ? filter : DEFAULT_VALUE}        onValuesChange={handleValueChange}        form={form}      >        <Form.Item name="grayscale" label="灰度">          <Slider min={0} max={100} />        </Form.Item>        <Form.Item name="blur" label="含糊">          <Slider min={0} max={100} />        </Form.Item>        <Form.Item name="contrast" label="对比度">          <Slider min={0} max={200} />        </Form.Item>        <Form.Item name="hue-rotate" label="色相旋转">          <Slider min={0} max={360} />        </Form.Item>        <Form.Item name="invert" label="反转色彩">          <Slider min={0} max={100} />        </Form.Item>        <Form.Item name="saturate" label="饱和度">          <Slider min={0} max={200} />        </Form.Item>        <Form.Item name="brightness" label="亮度">          <Slider min={0} max={200} />        </Form.Item>      </Form>    </div>  );};export default observer(Filter);

在调整了各个滤镜参数的时候,预览区的成果应该即时变更,整个流程走向大抵能够用上面的图来概括:

Preview组件中应用一个hook来解决数据的变更:

  useFilter({    displayCanvas,    memoryCanvas,    currentImg: currentImg.current,    filters: fileStore.actionMap[file.uid] || {},  });

这边留神任何数据的变更咱们都须要同时对两个canvas进行操作,能力保障后续的性能无误。Hook中会调用具体的DoAction操作,这个useFilter对应的就是doFilter,在这个doFilter中就是真正对canvas利用滤镜成果。

const doFilter = (canvas, filters, img) => {  const context = canvas.getContext("2d");  const transfer = [];  Object.keys(filters).forEach((key) => {    if (      ["grayscale", "invert", "saturate", "brightness", "contrast"].includes(        key      )    ) {      transfer.push(`${key}(${filters[key]}%)`);    } else if (key === "blur") {      transfer.push(`${key}(${filters[key]}px)`);    } else if (key === "hue-rotate") {      transfer.push(`${key}(${filters[key]}deg)`);    }  });  context.clearRect(0, 0, canvas.width, canvas.height);  context.filter = transfer.join(" ");  context.drawImage(img, 0, 0, canvas.width, canvas.height);};export default doFilter;

保留变更

调整好本人想要的参数之后就能够把这个变更保留下来,这里的实现逻辑其实就是把下面形象好的办法拼凑起来。

当触发保留之后:

  • 创立一个离屏canvas(在内存中的canvas

    const loadMemoryCanvas = (file) => {return new Promise((resolve) => {  const reader = new FileReader();  reader.readAsDataURL(file);  reader.onload = (readerEvent) => { const url = readerEvent.target.result; const image = new Image(); image.src = url; image.onload = () => { const originalWidth = image.width; const originalHeight = image.height; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); canvas.width = originalWidth; canvas.height = originalHeight; context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, originalWidth, originalHeight); resolve({   canvas,   image, }); };  };});};export default loadMemoryCanvas;
  • 把操作利用到这个canvas

      const { canvas, image } = await loadMemoryCanvas(file);  if (action) { if (action.type === ACTION_TYPE.FILTER) { doFilter(canvas, action, image); }  }
  • canvas转成一个File对象

    canvas.toBlob((blob) => { const newFile = new File([blob], file.name, {type: file.type, }); newFile.uid = file.uid; resolve(newFile); });
  • 替换掉Mobx外面的信息

    updateFile = async (uid) => {  const file = this.files.find((file) => file.uid === uid);  const newFile = await applyAction(file, this.actionMap[uid]);  runInAction(() => { if (uid === newFile.uid) { this.currentFile = newFile; } const list = toJS(this.files); const index = list.findIndex((file) => file.uid === uid); list[index] = newFile; this.files = list; this.actionMap[uid] = {};  });};

下载

下载的时候应用URL.createObjectURLFile对象转成一个链接,而后应用a标签进行下载,这里须要留神的是下载完之后要把这个链接销毁,不然会造成内存透露。

import moment from "moment";const getFileExtension = (fileName) => {  return fileName.slice(((fileName.lastIndexOf(".") - 1) >>> 0) + 2);};const generateName = () => {  return moment().format("YYYYMMDDHHmmss");};const download = async (file) => {  const downloadLink = document.createElement("a");  downloadLink.href = URL.createObjectURL(file);  downloadLink.download = `${generateName()}.${getFileExtension(file.name)}`;  downloadLink.click();  URL.revokeObjectURL(downloadLink.href);};export default download;

最初

本文到这里就完结了,然而咱们的图像编辑器之旅才刚刚开始,后续我会介绍更多对图像的操作,感兴趣的同学能够点点关注点点赞~欢送评论区或者私信交换~