今天和大家分享一款国内大佬开发的图片编辑器 fast-image-editor。文章将从如何使用到技术分析, 详细和大家介绍一下这款可视化工具的实现, 我相信大家可以从这篇文章的实现方案中受益匪浅。如果你觉得这个项目对你有帮助, 也可以在 github 上点个 star, 支持一下作者。(文末会附上github地址)
案例演示
快速启动
我们可以按照以下方式来获取并启动项目:
git clone git@github.com:jiechud/fast-image-editor.git yarn install || npm install yarn dev 启动服务 打开浏览器访问
功能特性
目前已支持的功能有:
- layout布局
- 文字编辑组件
- 图片编辑组件
- 画布放大缩小
- 画布右键菜单
- 图片下载
- 背景图支持
- 画布参考线
- 模版库
- 导出图片json
技术实现
项目采用 React umi 开发框架,采用 typescript 编写,图片编辑功能用的是 react-konva,考虑后期可能核心的编辑功能整体做成一个组件,所以没有 umi 里提供的 useModel 去做状态处理,采用的是flooks。技术栈如下:
img.png
大部分工具类的软件都有辅助线,方便拖拽元素的时候对齐,能让我们快速的做出漂亮的图片。辅助线实现过程稍微有些复杂,我们一步步说下实现过程。
原理讲解
左侧辅助线出现时机:
我们以节点2为移动的元素,通过上面的图观察我们可以看出,当左侧辅助线出现的时候,节点1的x坐标和节点2的x坐标相等的时候辅助线就会出现,我们移动节点2的时候动态去判断。
右侧辅助线出现时机:
我们以节点2为移动的元素,通过上面的图观察我们可以看出,当右侧辅助线出现的时候,节点1的x+width(坐标x+节点的宽度)和节点2的x坐标相等的时候辅助线就会出现,我们移动节点2的时候动态去判断。
辅助线规则
- 左侧辅助线 x1(x) = x2(x)
- 右侧辅助线 x1(x+width) = x2(x)
- 水平中间辅助线 x1(x+width/2) = x2(x+ width / 2)
- 顶部辅助线 x1(y) = x2(y)
- 底部辅助线 x1(y+height) = x(y)
- 垂直中间辅助线 x1(y+height/2) = x2(+height/2)
上面的公式我们以节点2为拖动的元素,节点1为目标元素。当我们以节点1为拖动元素,节点2为目标元素,公式会有变化,大家可以自行尝试一下。
代码实现
上面我们分析出了一个节点的对比规则,画布上可能会有很多节点,让当前移动的节点去和剩下的元素去做比较。然后通过 Knova 的 layer 下的 children 获取所有元素,并记录位置,代码如下:
// 获取单个节点的位置信息 export const getLocationItem = (shapeObject: Konva.Shape) => { const id = shapeObject.id(); const width = shapeObject.width(); const height = shapeObject.height(); const x = shapeObject.x(); const y = shapeObject.y(); const locationItem: LocationItem = { id, w: width, h: height, x, // x坐标 y, // y坐标 l: x, // 左侧方向 r: x + width, // 右侧方向 t: y, // 顶部方向 b: y + height, // 底部方向 lc: x + (width / 2), // 水平居中 tc: y + (height / 2) // 垂直居中 } return locationItem; // console.log('locationItem=>', locationItem); } // 设置所有节点的信息 export const setLocationItems = (layer: Konva.Layer) => { locationItems = []; layer.children?.forEach(item => { if (item.className !== 'Transformer') { locationItems.push(getLocationItem(item)); } }); }
在拖动节点的时候,调用detectionToLine方法根据计算规则画线:
/** * 拖动节点,shape代表当前拖动的节点 */ export const detectionToLine = (layer: Konva.Layer, shape: Konva.Shape) => { const locationItem = getLocationItem(shape); // 当前节点的位置信息 // 过滤当前节点,和剩下的节点做比较 const compareLocations = locationItems.filter((item: LocationItem) => item.id !== locationItem.id); removeLines(layer); // 移除之前划过的线 compareLocations.forEach((item: LocationItem) => { if ((Math.abs(locationItem.x - item.x) <= threshold)) { // 处理左侧方向 shape.setPosition({ x: item.x, y: locationItem.y }) addLine(layer, locationItem, item, DIRECTION.left) } if ((Math.abs(locationItem.x - item.r) <= threshold)) { // 处理右侧 shape.setPosition({ x: item.r, y: locationItem.y }) addLine(layer, locationItem, item, DIRECTION.right); } if ((Math.abs(locationItem.lc - item.lc) <= threshold)) { // 处理水平居中 shape.setPosition({ x: item.lc - (locationItem.w / 2), y: locationItem.y }) addLine(layer, locationItem, item, DIRECTION.leftCenter); } // 拖动节点和目标节点互换的判断条件 if ((Math.abs(locationItem.r - item.x) <= threshold)) { shape.setPosition({ x: item.l - locationItem.w, y: locationItem.t }) addLine(layer,item,locationItem, DIRECTION.right) } if ((Math.abs(locationItem.r - item.r) <= threshold)) { // 右侧相等 shape.setPosition({ x: item.r - locationItem.w, y: locationItem.t }) addLine(layer,item,locationItem, DIRECTION.right) } if ((Math.abs(locationItem.y - item.y) <= threshold)) { // 处理垂直方向顶部 shape.setPosition({ x: locationItem.x, y: item.y }) addLine(layer, locationItem, item, DIRECTION.top); } if ((Math.abs(locationItem.y - item.b) <= threshold)) { // 处理底部 shape.setPosition({ x: locationItem.x, y: item.b }) addLine(layer, locationItem, item, DIRECTION.bottom); } if ((Math.abs(locationItem.tc - item.tc) <= threshold)) { // 处理垂直顶部居中 shape.setPosition({ x: locationItem.x, y: item.tc - (locationItem.h /2 ) }) addLine(layer, locationItem, item, DIRECTION.topCenter); } // 拖动节点和目标节点互换的判断条件 if ((Math.abs(locationItem.b - item.t) <= threshold)) { // 处理垂底部方向 shape.setPosition({ x: locationItem.l, y: item.t - locationItem.h }) addLine(layer,item,locationItem, DIRECTION.bottom) } if ((Math.abs(locationItem.b - item.b) <= threshold)) { // 右侧相等 shape.setPosition({ x: locationItem.l, y: item.b - locationItem.h }) addLine(layer,item,locationItem, DIRECTION.bottom) } }); }
达到阈值,添加辅助线
我们可以看到在对比的时候有这样的代码:
Math.abs(locationItem.b - item.b) <= threshold)
这块主要是用来判断两个节点之间的距离小于设定的阈值,触发添加辅助线。
还有一段设置当前节点位置的代码,如下:
shape.setPosition({ x: locationItem.l, y: item.t - locationItem.h })
这块的主要作用是辅助线出现的是,节点移动的位置不超过阈值,节点不会动。
添加辅助线
添加辅助线会传入拖动的元素和目标元素,以及哪个方向要出现辅助线。
addLine(layer, locationItem, item, DIRECTION.left)
根据拖动的元素和目标元素以及方向计算出辅助线出现的位置:
/** * * @param sourceItem 拖动的图形 * @param targetItem 目标图形 * @param targetItem 方向 */ const getPoints = (sourceItem: LocationItem, targetItem: LocationItem, direction: DIRECTION) => { let minItem: LocationItem, maxItem: LocationItem; let points: any = []; let po = { [DIRECTION.left]: [ [targetItem.l, sourceItem.b, targetItem.l, targetItem.t], [targetItem.l, targetItem.b, targetItem.l, sourceItem.t] ], [DIRECTION.right]: [ [targetItem.r, sourceItem.b, targetItem.r, targetItem.t], [targetItem.r, targetItem.b, targetItem.r, sourceItem.t] ], [DIRECTION.leftCenter]: [ [targetItem.lc, sourceItem.b, targetItem.lc, targetItem.t], [targetItem.lc, targetItem.b, targetItem.lc, sourceItem.t] ], [DIRECTION.top]: [ [sourceItem.r, targetItem.t, targetItem.l, targetItem.t], [targetItem.r, targetItem.t, sourceItem.l, targetItem.t] ], [DIRECTION.bottom]: [ [sourceItem.r, targetItem.b, targetItem.l, targetItem.b], [targetItem.r, targetItem.b, sourceItem.l, targetItem.b] ], [DIRECTION.topCenter]: [ [sourceItem.r, targetItem.tc, targetItem.l, targetItem.tc], [targetItem.r, targetItem.tc, sourceItem.l, targetItem.tc] ] } switch (direction) { case DIRECTION.left: return sourceItem.y < targetItem.y ? po[DIRECTION.left][0] : po[DIRECTION.left][1]; case DIRECTION.right: // 目标图形是否在上边 return sourceItem.y < targetItem.y ? po[DIRECTION.right][0] : po[DIRECTION.right][1]; case DIRECTION.leftCenter: return sourceItem.y < targetItem.y ? po[DIRECTION.leftCenter][0] : po[DIRECTION.leftCenter][1]; case DIRECTION.top: return sourceItem.x < targetItem.x ? po[DIRECTION.top][0] : po[DIRECTION.top][1]; case DIRECTION.bottom: return sourceItem.x < targetItem.x ? po[DIRECTION.bottom][0] : po[DIRECTION.bottom][1]; case DIRECTION.topCenter: return sourceItem.x < targetItem.x ? po[DIRECTION.topCenter][0] : po[DIRECTION.topCenter][1]; default: break; } return points; }
添加辅助线方法,比较简单:
export const addLine = (layer: Konva.Layer, sourceItem: LocationItem, targetItem: LocationItem, direction: DIRECTION) => { // 计算出辅助线的位置新新 const points = getPoints(sourceItem, targetItem, direction); var greenLine = new Konva.Line({ points: points, stroke: 'green', strokeWidth: 1, lineJoin: 'round', dash: [10, 10] }) // greenLine.direction = direction lines.push(greenLine); layer.add(greenLine); layer.draw(); }
好啦, 今天的内容就到这里了, 如果觉得文章对你有帮助, 记得点赞 + 再看, 让更多的朋友从中受益~
github:
https://github.com/jiechud/fast-image-editor
作者: 杰出D