前语

这是一个针关于图画编辑的系列,我会陆陆续续完结包含但不限于:图画滤镜、高级滤镜、图画卷积、图画紧缩、水印、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;

最后

本文到这儿就完毕了,可是咱们的图画编辑器之旅才刚刚开端,后续我会介绍更多对图画的操作,感兴趣的同学能够点点重视点点赞~欢迎谈论区或许私信沟通~