微信小程序--canvas画布实现图片的编辑

概述

上传图片,编辑图片大小,添加文字,改变文字颜色等

详细

概述

微信小程序--canvas画布实现图片的编辑

详细

一、前期准备工作

软件环境:微信开发者工具
官方下载地址:微信开发者工具下载地址与更新日志 | 微信开放文档

1、基本需求。
  • 实现上传图片

  • 实现图片编辑

  • 实现添加文字

  • 实现导出图片

2、案例目录结构

image.png

二、程序实现具体步骤
1.index.js代码(canvas-drag)
// components/canvas-drag/index.js
const dragGraph = function ({ x, y, w, h, type, text, fontSize = 20, color = 'red', url }, canvas, factor) {
if (type === 'text') {
canvas.setFontSize(fontSize);
const textWidth = canvas.measureText(this.text).width;
const textHeight = fontSize + 10;
const halfWidth = textWidth / 2;
const halfHeight = textHeight / 2;
this.x = x + halfWidth;
this.y = y + halfHeight;
} else {
this.x = x;
this.y = y;
}
this.w = w;
this.h = h;
this.fileUrl = url;
this.text = text;
this.fontSize = fontSize;
this.color = color;
this.ctx = canvas;
this.rotate = 0;
this.type = type;
this.selected = true;
this.factor = factor;
this.MIN_WIDTH = 50;
this.MIN_FONTSIZE = 10;
}

dragGraph.prototype = {
/**
     * 绘制元素
     */
paint() {
this.ctx.save();
// TODO 剪切
// this._drawRadiusRect(0, 0, 700, 750, 300);
// this.ctx.clip();
// 由于measureText获取文字宽度依赖于样式,所以如果是文字元素需要先设置样式
if (this.type === 'text') {
this.ctx.setFontSize(this.fontSize);
this.ctx.setTextBaseline('middle');
this.ctx.setTextAlign('center');
this.ctx.setFillStyle(this.color);
}
// 选择区域的中心点
this.centerX = this.type === 'text' ? this.x : this.x + (this.w / 2);
this.centerY = this.type === 'text' ? this.y : this.y + (this.h / 2);
// 旋转元素
this.ctx.translate(this.centerX, this.centerY);
this.ctx.rotate(this.rotate * Math.PI / 180);
this.ctx.translate(-this.centerX, -this.centerY);
// 渲染元素
if (this.type === 'text') {
this.ctx.fillText(this.text, this.x, this.y);
} else if (this.type === 'image') {
this.ctx.drawImage(this.fileUrl, this.x, this.y, this.w, this.h);
}
// 如果是选中状态,绘制选择虚线框,和缩放图标、删除图标
if (this.selected) {
this.ctx.setLineDash([10, 10]);
this.ctx.setLineWidth(2);
this.ctx.setStrokeStyle('red');
this.ctx.lineDashOffset = 10;

if (this.type === 'text') {
const textWidth = this.ctx.measureText(this.text).width;
const textHeight = this.fontSize + 10
const halfWidth = textWidth / 2;
const halfHeight = textHeight / 2;
const textX = this.x - halfWidth;
const textY = this.y - halfHeight;
this.ctx.strokeRect(textX, textY, textWidth, textHeight);
this.ctx.drawImage('./icon/close.png', textX - 15, textY - 15, 30, 30);
this.ctx.drawImage('./icon/scale.png', textX + textWidth - 15, textY + textHeight - 15, 30, 30);
} else {
this.ctx.strokeRect(this.x, this.y, this.w, this.h);
this.ctx.drawImage('./icon/close.png', this.x - 15, this.y - 15, 30, 30);
this.ctx.drawImage('./icon/scale.png', this.x + this.w - 15, this.y + this.h - 15, 30, 30);
}
}

this.ctx.restore();
},
/**
     * 判断点击的坐标落在哪个区域
     * @param {*} x 点击的坐标
     * @param {*} y 点击的坐标
     */
isInGraph(x, y) {
const selectW = this.type === 'text' ? this.ctx.measureText(this.text).width : this.w;
const selectH = this.type === 'text' ? this.fontSize + 10 : this.h;

// 删除区域左上角的坐标和区域的高度宽度
const delW = 30;
const delH = 30;
const delX = this.type === 'text' ? this.x - (selectW / 2) : this.x;
const delY = this.type === 'text' ? this.y - (selectH / 2) : this.y;
// 旋转后的删除区域坐标
const transformDelX = this._getTransform(delX, delY, this.rotate - this._getAngle(this.centerX, this.centerY, delX, delY)).x - (delW / 2);
const transformDelY = this._getTransform(delX, delY, this.rotate - this._getAngle(this.centerX, this.centerY, delX, delY)).y - (delH / 2);

// 变换区域左上角的坐标和区域的高度宽度
const scaleW = 30;
const scaleH = 30;
const scaleX = this.type === 'text' ? this.x + (selectW / 2) : this.x + selectW;
const scaleY = this.type === 'text' ? this.y + (selectH / 2) : this.y + selectH;
// 旋转后的变换区域坐标
const transformScaleX = this._getTransform(scaleX, scaleY, this.rotate + this._getAngle(this.centerX, this.centerY, scaleX, scaleY)).x - (scaleW / 2);
const transformScaleY = this._getTransform(scaleX, scaleY, this.rotate + this._getAngle(this.centerX, this.centerY, scaleX, scaleY)).y - (scaleH / 2);

const moveX = this.type === 'text' ? this.x - (selectW / 2) : this.x;
const moveY = this.type === 'text' ? this.y - (selectH / 2) : this.y;

// 测试使用
// this.ctx.setLineWidth(1);
// this.ctx.setStrokeStyle('red');
// this.ctx.strokeRect(transformDelX, transformDelY, delW, delH);

// this.ctx.setLineWidth(1);
// this.ctx.setStrokeStyle('black');
// this.ctx.strokeRect(transformScaleX, transformScaleY, scaleW, scaleH);

if (x - transformScaleX >= 0 && y - transformScaleY >= 0 &&
transformScaleX + scaleW - x >= 0 && transformScaleY + scaleH - y >= 0) {
// 缩放区域
return 'transform';
} else if (x - transformDelX >= 0 && y - transformDelY >= 0 &&
transformDelX + delW - x >= 0 && transformDelY + delH - y >= 0) {
// 删除区域
return 'del';
} else if (x - moveX >= 0 && y - moveY >= 0 &&
moveX + selectW - x >= 0 && moveY + selectH - y >= 0) {
// 移动区域
return 'move';
}
// 不在选择区域里面
return false;
},
/**
     * 两点求角度
     * @param {*} px1 
     * @param {*} py1 
     * @param {*} px2 
     * @param {*} py2 
     */
_getAngle(px1, py1, px2, py2) {
const x = px2 - px1;
const y = py2 - py1;
const hypotenuse = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
//斜边长度
const cos = x / hypotenuse;
const radian = Math.acos(cos);
const angle = 180 / (Math.PI / radian);
return angle;
},
/**
     * 点选择一定角度之后的坐标
     * @param {*} x 
     * @param {*} y 
     * @param {*} rotate 旋转的角度
     */
_getTransform(x, y, rotate) {
const angle = (Math.PI / 180) * (rotate);
const r = Math.sqrt(Math.pow((x - this.centerX), 2) + Math.pow((y - this.centerY), 2));
const a = Math.sin(angle) * r;
const b = Math.cos(angle) * r;
return {
x: this.centerX + b,
y: this.centerY + a,
};
},
/**
     * 
     * @param {*} px 手指按下去的坐标
     * @param {*} py 手指按下去的坐标
     * @param {*} x 手指移动到的坐标
     * @param {*} y 手指移动到的坐标
     * @param {*} currentGraph 当前图层的信息
     */
transform(px, py, x, y, currentGraph) {
// 获取选择区域的宽度高度
if (this.type === 'text') {
this.ctx.setFontSize(this.fontSize);
}

const centerX = this.type === 'text' ? this.x : this.x + (this.w / 2);
const centerY = this.type === 'text' ? this.y : this.y + (this.h / 2);

const diffXBefore = px - centerX;
const diffYBefore = py - centerY;
const diffXAfter = x - centerX;
const diffYAfter = y - centerY;

const angleBefore = Math.atan2(diffYBefore, diffXBefore) / Math.PI * 180;
const angleAfter = Math.atan2(diffYAfter, diffXAfter) / Math.PI * 180;

// 旋转的角度
this.rotate = currentGraph.rotate + angleAfter - angleBefore;

const lineA = Math.sqrt(Math.pow((centerX - px), 2) + Math.pow((centerY - py), 2));
const lineB = Math.sqrt(Math.pow((centerX - x), 2) + Math.pow((centerY - y), 2));
if (this.type === 'image') {
const w = currentGraph.w + (lineB - lineA);
const h = currentGraph.h + (lineB - lineA);
this.w = w <= this.MIN_WIDTH ? this.MIN_WIDTH : w;
this.h = h <= this.MIN_WIDTH ? this.MIN_WIDTH : h;

if (w > this.MIN_WIDTH && h > this.MIN_WIDTH) {
// 放大 或 缩小
this.x = currentGraph.x - (lineB - lineA) / 2;
this.y = currentGraph.y - (lineB - lineA) / 2;
}
} else if (this.type === 'text') {
const fontSize = currentGraph.fontSize * ((lineB - lineA) / lineA + 1);
this.fontSize = fontSize <= this.MIN_FONTSIZE ? this.MIN_FONTSIZE : fontSize;
}
},
/**
     * 画圆角矩形
     */
_drawRadiusRect(x, y, w, h, r) {
const br = r / 2;
this.ctx.beginPath();
this.ctx.moveTo(this.toPx(x + br), this.toPx(y));    // 移动到左上角的点
this.ctx.lineTo(this.toPx(x + w - br), this.toPx(y));
this.ctx.arcTo(this.toPx(x + w), this.toPx(y), this.toPx(x + w), this.toPx(y + br), this.toPx(br));
this.ctx.lineTo(this.toPx(x + w), this.toPx(y + h - br));
this.ctx.arcTo(this.toPx(x + w), this.toPx(y + h), this.toPx(x + w - br), this.toPx(y + h), this.toPx(br));
this.ctx.lineTo(this.toPx(x + br), this.toPx(y + h));
this.ctx.arcTo(this.toPx(x), this.toPx(y + h), this.toPx(x), this.toPx(y + h - br), this.toPx(br));
this.ctx.lineTo(this.toPx(x), this.toPx(y + br));
this.ctx.arcTo(this.toPx(x), this.toPx(y), this.toPx(x + br), this.toPx(y), this.toPx(br));
},
toPx(rpx) {
return rpx * this.factor;
},
}
Component({
/**
     * 组件的属性列表
     */
properties: {
graph: {
type: Object,
value: {},
observer: 'onGraphChange',
},
bgColor: {
type: String,
value: '',
},
bgImage: {
type: String,
value: '',
},
width: {
type: Number,
value: 750,
},
height: {
type: Number,
value: 750,
},
},

/**
     * 组件的初始数据
     */
data: {

},

attached() {
const sysInfo = wx.getSystemInfoSync();
const screenWidth = sysInfo.screenWidth;
this.factor = screenWidth / 750;

if (typeof this.drawArr === 'undefined') {
this.drawArr = [];
}
this.ctx = wx.createCanvasContext('canvas-label', this);
this.draw();
},

/**
     * 组件的方法列表
     */
methods: {
toPx(rpx) {
return rpx * this.factor;
},
onGraphChange(n, o) {
if (JSON.stringify(n) === '{}') return;
this.drawArr.push(new dragGraph(Object.assign({
x: 30,
y: 30,
}, n), this.ctx, this.factor));
this.draw();
},
draw() {
if (this.data.bgImage !== '') {
this.ctx.drawImage(this.data.bgImage, 0, 0, this.toPx(this.data.width), this.toPx(this.data.height));
}
if (this.data.bgColor !== '') {
this.ctx.save();
this.ctx.setFillStyle(this.data.bgColor);
this.ctx.fillRect(0, 0, this.toPx(this.data.width), this.toPx(this.data.height));
this.ctx.restore();
}
this.drawArr.forEach((item) => {
item.paint();
});
return new Promise((resolve) => {
this.ctx.draw(false, () => {
resolve();
});
});
},
start(e) {
const { x, y } = e.touches[0];
this.tempGraphArr = [];
this.drawArr && this.drawArr.forEach((item, index) => {
item.selected = false;
const action = item.isInGraph(x, y);
if (action) {
if (action === 'del') {
this.drawArr.splice(index, 1);
this.ctx.clearRect(0, 0, this.toPx(this.data.width), this.toPx(this.data.height));
this.ctx.draw();
} else if (action === 'transform' || action === 'move') {
item.action = action;
this.tempGraphArr.push(item);
// 保存点击时的坐标
this.currentTouch = { x, y };

}
}
});
// 保存点击时元素的信息
if (this.tempGraphArr.length > 0) {
const lastIndex = this.tempGraphArr.length - 1;
this.tempGraphArr[lastIndex].selected = true;
this.currentGraph = Object.assign({}, this.tempGraphArr[lastIndex]);
}
this.draw();
},
move(e) {
const { x, y } = e.touches[0];
if (this.tempGraphArr && this.tempGraphArr.length > 0) {
const currentGraph = this.tempGraphArr[this.tempGraphArr.length - 1];
if (currentGraph.action === 'move') {
currentGraph.x = this.currentGraph.x + (x - this.currentTouch.x);
currentGraph.y = this.currentGraph.y + (y - this.currentTouch.y);
} else if (currentGraph.action === 'transform') {
currentGraph.transform(this.currentTouch.x, this.currentTouch.y, x, y, this.currentGraph);
}
this.draw();
}
},
end(e) {
this.tempGraphArr = [];
},
export() {
return new Promise((resolve, reject) => {
this.drawArr = this.drawArr.map((item) => {
item.selected = false;
return item;
});
this.draw().then(() => {
wx.canvasToTempFilePath({
canvasId: 'canvas-label',
success: (res) => { resolve(res.tempFilePath); },
fail: (e) => { reject(e); },
}, this);
});
})
},
changColor(color) {
const selected = this.drawArr.filter((item) => item.selected);
if (selected.length > 0) {
selected[0].color = color;
}
this.draw();
},
changeBgColor(color) {
this.data.bgImage = '';
this.data.bgColor = color;
this.draw();
},
changeBgImage(url) {
this.data.bgColor = '';
this.data.bgImage = url;
this.draw();
}
}
})

2.index.wxss代码(canvas-drag)
/* components/canvas-drag/index.wxss */
.movable-label {
margin-top: 300rpx;
width: 750rpx;    
height: 400rpx;
background: #eee;
}
.movable-block {
width: 120rpx;
height: 120rpx;
background: #ccc;
}
.movable-block .image-con {
width: 100%;
height: 100%;
}
3.index.wxml代码(canvas-drag)
<!--components/canvas-drag/index.wxml-->
<canvas canvas-id='canvas-label' 
disable-scroll="true"
bindtouchstart="start"
bindtouchmove="move"
bindtouchend="end"
style='width: {{width}}rpx; height: {{height}}rpx;'></canvas>
4.index.js逻辑代码(index)

a.部分的功能实现

import CanvasDrag from '../../components/canvas-drag/canvas-drag';
Page({
data: {
graph: {},
},
/**
     * 添加测试图片
     */
onAddTest() {
this.setData({
graph: {
w: 120,
h: 120,
type: 'image',
url: '../../assets/images/test.jpg',
}
});
},
/**
     * 添加图片
     */
onAddImage() {
wx.chooseImage({
success: (res) => {
this.setData({
graph: {
w: 200,
h: 200,
type: 'image',
url: res.tempFilePaths[0],
}
});
}
})
},
/**
     * 添加文本
     */
onAddText() {
this.setData({
graph: {
type: 'text',
text: 'helloworld',
}
});
},
/**
     * 导出图片
     */
onExport() {
CanvasDrag.export()
.then((filePath) => {
console.log(filePath);
wx.previewImage({
urls: [filePath]
})
})
.catch((e) => {
console.error(e);
})
},
/**
     * 改变文字颜色
     */
onChangeColor() {
CanvasDrag.changFontColor('blue');
},
/**
     * 改变背景颜色
     */
onChangeBgColor() {
CanvasDrag.changeBgColor('yellow');
},
/**
     * 改变背景照片
     */
onChangeBgImage() {
CanvasDrag.changeBgImage('../../assets/images/test.jpg');
},

})
三、案例运行效果图

1.gif

四、总结与备注

暂无

参加比赛的作品,开发周期一个月,使用了 Wafer2 框架,后台采用腾讯云提供的 Node.js SDK 接入对象存储 API ,前端核心代码实现了类似于图片编辑器的功能,支持图片和文字的移动、旋转、缩放、生成预览图以及编辑状态的保存,动画部分采用 CSS 动画实现小程序中的模态输入框部分使用了自己封装的 InputBox 组件代码已移除 AppId 等敏感信息,可自行添加自己的 AppId 和 AppSecret 以配置后台环境,实现登录测试,详细添加方法见下文「使用方法」,若本地运行可通过修改 app.json 文件中 page 字段的顺序来查看不同页面微信小程序定制需求请联系作者微信:aweawds (注明来意)效果展示      使用方法首先点击右上角 Star ʕ •ᴥ•ʔ获取Demo代码执行 git clone https://github.com/goolhanrry/Weapp-Demo-LemonJournal.git或 点击此处 下载最新版本的代码解压后在微信开发者工具中打开 Weapp-Demo-LemonJournal 文件夹即可如需进行登录测试,还要执行以下步骤准备好自己的 AppId 和 AppSecret(可在微信公众平台注册后获取)在 project.config.json 的 appid 字段中填入 AppId在 /client/utils/util.js 中相应位置填入 AppId 和 AppSecret在微信开发者工具中重新导入整个项目,上传后台代码后编译运行即可核心代码组件的移动、旋转和缩放主要思路是把  标签(对应图片)和  标签(对应文字)封装在同一个自定义组件  中,通过对外暴露的 text 变量是否为空来进行条件渲染,然后绑定 onTouchStart() 、onTouchEnd() 和 onTouchMove() 三个事件来对整个组件的位置、角度、大小、层级以及 “旋转” 和 “移除” 两个按钮的行为进行操作onTouchStart: function (e) {     // 若未选中则直接返回     if (!this.data.selected) {         return     }     switch (e.target.id) {         case 'sticker': {             this.touch_target = e.target.id             this.start_x = e.touches[0].clientX * 2             this.start_y = e.touches[0].clientY * 2             break         }         case 'handle': {             // 隐藏移除按钮             this.setData({                 hideRemove: true             })             this.touch_target = e.target.id             this.start_x = e.touches[0].clientX * 2             this.start_y = e.touches[0].clientY * 2             this.sticker_center_x = this.data.stickerCenterX;             this.sticker_center_y = this.data.stickerCenterY;             this.remove_center_x = this.data.removeCenterX;             this.remove_center_y = this.data.removeCenterY;             this.handle_center_x = this.data.handleCenterX;             this.handle_center_y = this.data.handleCenterY;             this.scale = this.data.scale;             this.rotate = this.data.rotate;             break         }     } }, onTouchEnd: function (e) {     this.active()     this.touch_target = ''     // 显示移除按钮     this.setData({         removeCenterX: 2 * this.data.stickerCenterX - this.data.handleCenterX,         removeCenterY: 2 * this.data.stickerCenterY - this.data.handleCenterY,         hideRemove: false     })     // 若点击移除按钮则触发移除事件,否则触发刷新数据事件     if (e.target.id === 'remove') {         this.triggerEvent('removeSticker', this.data.sticker_id)     } else {         this.triggerEvent('refreshData', this.data)     } }, onTouchMove: function (e) {     // 若无选中目标则返回     if (!this.touch_target) {         return     }     var current_x = e.touches[0].clientX * 2     var current_y = e.touches[0].clientY * 2     var diff_x = current_x - this.start_x     var diff_y = current_y - this.start_y     switch (e.target.id) {         case 'sticker': {             // 拖动组件则所有控件同时移动             this.setData({                 stickerCenterX: this.data.stickerCenterX   diff_x,                 stickerCenterY: this.data.stickerCenterY   diff_y,                 removeCenterX: this.data.removeCenterX   diff_x,                 removeCenterY: this.data.removeCenterY   diff_y,                 handleCenterX: this.data.handleCenterX   diff_x,                 handleCenterY: this.data.handleCenterY   diff_y             })             break         }         case 'handle': {             // 拖动操作按钮则原地旋转缩放             this.setData({                 handleCenterX: this.data.handleCenterX   diff_x,                 handleCenterY: this.data.handleCenterY   diff_y             })             var diff_x_before = this.handle_center_x - this.sticker_center_x;             var diff_y_before = this.handle_center_y - this.sticker_center_y;             var diff_x_after = this.data.handleCenterX - this.sticker_center_x;             var diff_y_after = this.data.handleCenterY - this.sticker_center_y;             var distance_before = Math.sqrt(diff_x_before * diff_x_before   diff_y_before * diff_y_before);             var distance_after = Math.sqrt(diff_x_after * diff_x_after   diff_y_after * diff_y_after);             var angle_before = Math.atan2(diff_y_before, diff_x_before) / Math.PI * 180;             var angle_after = Math.atan2(diff_y_after, diff_x_after) / Math.PI * 180;             this.setData({                 scale: distance_after / distance_before * this.scale,                 rotate: angle_after - angle_before   this.rotate             })             break         }     }     this.start_x = current_x;     this.start_y = current_y; }编辑状态的保存一篇手帐包含的组件类型包括 sticker(软件自带的贴纸)、image(用户上传的图片)和 text(自定义文字)三种,全部保存在一个如下格式的 json 对象中,每个独立组件都包含了一个不重复的 id 以及相关的信息,保存时由客户端生成该对象并编码成 json 字符串存储在数据库,恢复编辑状态时通过解析 json 字符串获得对象,再由编辑页面渲染{     "backgroundId": "5",                                        背景图id     "assemblies": [         {             "id": "jhjg",                                       组件id             "component_type": "image",                          组件类型(自定义图片)             "image_url": "https://example.com/jhjg.png",        图片地址             "stickerCenterX": 269,                              中心横坐标             "stickerCenterY": 664,                              中心纵坐标             "scale": 1.7123667831396403,                        缩放比例             "rotate": -3.0127875041833434,                      旋转角度             "wh_scale": 1,                                      图片宽高比             "z_index": 19                                       组件层级         },         {             "id": "gs47",             "component_type": "text",                           组件类型(文字)             "text": "test",                                     文字内容             "stickerCenterX": 479,             "stickerCenterY": 546,             "scale": 1.808535318980528,             "rotate": 29.11614626607893,             "z_index": 10         },         {             "id": "chjn",             "component_type": "sticker",                        组件类型(贴纸)             "sticker_type": "food",                             贴纸类型             "sticker_id": "1",                                  贴纸id             "image_url": "https://example.com/weapp/stickers/food/1.png",             "stickerCenterX": 277,             "stickerCenterY": 260,             "scale": 1.3984276885130673,             "rotate": -16.620756913892055,             "z_index": 5         }     ] }
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

西安未央

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值