共计 10771 个字符,预计需要花费 27 分钟才能阅读完成。
前言
公众号:【可乐前端】,期待关注交换,分享一些有意思的前端常识
这是一个针对于图像编辑的系列,我会陆陆续续实现包含但不限于:图像滤镜、高级滤镜、图像卷积、图像压缩、水印、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
的交互依赖了 antd
的Menu
组件,这里我略微批改了一下菜单组件,把具体的图像操作放在了具体的下拉菜单中:
那 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.createObjectURL
把File
对象转成一个链接,而后应用 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;
最初
本文到这里就完结了,然而咱们的图像编辑器之旅才刚刚开始,后续我会介绍更多对图像的操作,感兴趣的同学能够点点关注点点赞~欢送评论区或者私信交换~