代码地址:https://github.com/doterlin/emote-maker-mp
大约三四年前,斗图文化兴起,顿时聊天氛围就变得生动多彩,生龙活虎,其乐融融。
人们不再满足于文字交流,语音和视频又因自身腼腆原因不能完全表达意境,所以图片成了最佳表达自身情感的最佳媒介。
那些聊天软件内置的表情又生硬又不够丰富,显得比较刻板,看了十几年“小黄头”也该腻了;老年人式的表情包也显得大家“土”气,表情包“革命”蓄势待发。
接着是部分IP表情包开始兴起,以及一些网络红人也陆续被制作成表情,以及早就在贴吧论坛玩过的那些图也用到聊天软件,越来越多人发现在表达上这些图片表情更加出众:
“啊,这玩意真带劲!”
但是,带劲归带劲,社交最主要的是互动。
表情总不能你发你的,我发我的,表情之前没有关联性,也无对话可言,那社交软件不就很没面子。
于是慢慢在这些表情针对一些常用对话加到表情包,开始形成一些对话表情包,甚至是系列表情包。
这些表情包的出现使得斗图一下子氛围变得非常活跃,并巩固了“熊猫”、“蘑菇头”成为表情包标准“包装”的地位。
张学友
和D'Angelo Dinero
都想不到自己能以这种方式火了。
能快速自定义表情文字的需求也就慢慢逐渐变多了,于是程序猿们就造了一些表情制作器给大家用,把一些经典表情和“熊猫”、“蘑菇头”这些表情加上一些自己的文字,就能让表情包说出自己想说的话。
我三四年前也做了一个,现在突然翻到了代码,可以看到技术还是比较老,但还是想分享下。挂着几年也就几K用户。
技术栈
代码地址:https://github.com/doterlin/emote-maker-mp
- mpvue
- 组件库 iview-weapp
- vue
体验二维码:
核心实现
表情制作
表情制作的核心是使用canvas
实现。
- 图片载入、裁剪
- 文字输入、拖拽、字号、颜色
- 表情生成
核心组件代码:
<template>
<div class="maker-container page">
<canvas v-show="!showCropper" canvas-id="maker" class="maker" style="width: 600rpx; height: 600rpx;" @touchstart="touchstart" @touchmove="touchmove" @touchend="touchend" />
<div class="flex flex-bet maker-header">
<button @click="doCustomer" class="m-button">自定义图片</button>
<button open-type="share" class="m-button warn">分享一下吧!</button>
</div>
<div class="maker-area" v-if="inited">
<div class="input-wrapper flex flex-bet">
<i-input :value="userText[selectedIndex].txt" @change="changeTxt" :placeholder="userText[selectedIndex].plc" maxlength="-1" class="input" />
<i-icon type="right" size="28" color="#3EC983" i-class="bold" @click="completeText" />
<i-icon type="close" size="22" color="#D34827" i-class="bold" class="ml20" @click="removeLastText" />
</div>
<!-- <i-divider content="可拖动文字进行位移" height="32" class="divider-txt"></i-divider> -->
<div class="setting">
<span class="label">字号</span>
<slider
activeColor="#2d8cf0"
min="10"
max="50"
:value="userText[selectedIndex].fontSize"
@changing="changeFontsize"
@change="changeFontsize"
class="input-number"
/>
<!-- <i-input-number min="10" max="50" :value="userText[selectedIndex].fontSize" @change="changeFontsize" class="input-number"/> -->
</div>
<div class="setting">
<span class="label">颜色</span>
<color-selector @changeColor="changeColor" :currentColor="userText[selectedIndex].currentColor" />
</div>
<div class="btn-area">
<!-- <button open-type="share" class="m-button warn share-button">分享模版</button> -->
<button @click="doMake" class="m-button save large">生成表情</button>
</div>
</div>
<!-- cropper -->
<div class="cropper-area" v-show="showCropper">
<div class="cropper-wrapper">
<mpvue-cropper
ref="cropper"
:option="cropperOpt"
@ready="cropperReady"
@beforeDraw="cropperBeforeDraw"
@beforeImageLoad="cropperBeforeImageLoad"
@beforeLoad="cropperLoad"
></mpvue-cropper>
</div>
<div class="cropper-buttons" :style="{ color: cropperOpt.boundStyle.color }">
<div class="upload btn" @tap="uploadTap">更换图片</div>
<div class="flex">
<div class="getCropperImage btn" :style="{ backgroundColor: '#D34827' }" @tap="showCropper = false">取消</div>
<div class="getCropperImage btn" :style="{ backgroundColor: cropperOpt.boundStyle.color }" @tap="getCropperImage">确定</div>
</div>
</div>
</div>
</div>
</template>
<script>
import colorSelector from '../../components/color-selector'
import { doAnimationFrame, abortAnimationFrame, rpx2px } from '../../utils'
import MpvueCropper from 'mpvue-cropper'
let wecropper
const device = wx.getSystemInfoSync()
const width = device.windowWidth
const height = device.windowHeight - 100 * width / 750
let ctx
const initText = [
{
txt: '输入文字',
currentColor: '#000',
fontSize: 20,
x: rpx2px(300),
y: rpx2px(600) - 50
},
{
txt: '',
currentColor: '#000',
fontSize: 20,
x: rpx2px(300),
y: rpx2px(600) - 30
},
{
txt: '',
currentColor: '#000',
fontSize: 20,
x: rpx2px(300),
y: rpx2px(600) - 10
}
]
export default {
components: {
'color-selector': colorSelector,
MpvueCropper
},
data () {
return {
// userText: initText.map(item => ({...item})),
inited: false,
oneText: {
txt: '',
plc: '输入第{{index}}组文字, 可拖动文字',
x: rpx2px(300),
y: rpx2px(600) - 50
},
userText: [],
curFontSize: 16,
curColor: '#000',
selectedIndex: 0, // 当前操作的文本index
path: '', // 加载下来的模版的temp path,要想在canvas里面绘制img,必须加载到本地,不能直接引用远程地址
showCropper: false,
cropperOpt: {
// cropper配置
id: 'cropper',
targetId: 'targetCropper',
pixelRatio: device.pixelRatio,
width,
height,
scale: 4,
zoom: 8,
cut: {
x: (width - rpx2px(600)) / 2,
y: (height - rpx2px(600)) / 2,
width: rpx2px(600),
height: rpx2px(600)
},
boundStyle: {
color: '#04b00f',
mask: 'rgba(0,0,0,0.8)',
lineWidth: 1
}
}
}
},
watch: {
userText: {
handler: function () {
this.updateCanvas()
},
deep: true
}
},
methods: {
doCustomer () {
this.showCropper = true
},
removeLastText () {
if (this.selectedIndex === 0) return (this.userText[this.selectedIndex].txt = '')
this.selectedIndex--
this.userText.splice(-1)
},
completeText () {
if (this.userText[this.selectedIndex].txt === '') {
return wx.showToast({
title: '请输入文字哦!',
icon: 'none',
duration: 1000
})
}
this.selectedIndex++
this.addText()
},
addText () {
let _oneText = {
...this.oneText,
// txt: this.oneText.txt.replace('{{index}}', this.selectedIndex + 1),
plc: this.oneText.plc.replace('{{index}}', this.selectedIndex + 1),
currentColor: this.curColor,
fontSize: this.curFontSize,
y: rpx2px(600) - Math.abs(50 - 20 * this.selectedIndex)
}
this.userText.push(_oneText)
// this.selectedIndex++
},
touchstart (e) {
this.userText[this.selectedIndex].x = e.x
this.userText[this.selectedIndex].y = e.y
this.updateCanvas()
},
touchmove (e) {
this.userText[this.selectedIndex].x = e.x
this.userText[this.selectedIndex].y = e.y
this.animationId = doAnimationFrame(this.updateCanvas) // touch move的时候节流一下 可能性能会好些(心理作用😂 )
},
touchend (e) {
abortAnimationFrame(this.animationId)
},
changeTxt ({ mp }) {
this.userText[this.selectedIndex]['txt'] = mp.detail.detail.value
},
changeColor (color) {
this.userText[this.selectedIndex]['currentColor'] = color
},
changeFontsize ({ mp }) {
this.userText[this.selectedIndex]['fontSize'] = mp.detail.value
},
changeSelectedIndex ({ mp }) {
this.selectedIndex = mp.detail.key
},
updateCanvas () {
ctx.drawImage(this.path, 0, 0, rpx2px(600), rpx2px(600))
ctx.setTextAlign('center') // 必须每次在updateCanvas重新设置,否则模拟器上生效但真机下不会生效
this.userText.forEach(item => {
ctx.font = `bold ${item.fontSize}px/${item.fontSize}px sans-serif`
ctx.setFillStyle(item.currentColor)
ctx.fillText(item.txt, item.x, item.y)
})
ctx.draw()
},
doMake () {
wx.canvasToTempFilePath({
canvasId: 'maker',
success: function (res) {
wx.previewImage({
current: res.tempFilePath,
urls: [res.tempFilePath]
})
}
})
},
share () {},
cropperReady (...args) {
console.log('cropper ready!')
},
cropperBeforeImageLoad (...args) {
console.log('before image load')
},
cropperLoad (...args) {
console.log('image loaded')
},
cropperBeforeDraw (...args) {
// Todo: 绘制水印等等
},
uploadTap () {
wx.chooseImage({
count: 1, // 默认9
sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: res => {
const src = res.tempFilePaths[0]
// 获取裁剪图片资源后,给data添加src属性及其值
wecropper.pushOrigin(src)
}
})
},
getCropperImage () {
wecropper
.getCropperImage({ original: true })
.then(src => {
// wx.previewImage({
// current: '', // 当前显示图片的http链接
// urls: [src] // 需要预览的图片http链接列表
// })
this.initImg(src)
this.showCropper = false
})
.catch(e => {
console.error('获取图片失败')
this.showCropper = false
wx.showToast({
title: '自定义图片失败!',
icon: 'none',
duration: 1000
})
})
},
initImg (url) {
const imageResource = url || this.$root.$mp.query.url
wx.getImageInfo({
src: imageResource,
success: res => {
// 重置文本
this.userText = []
this.selectedIndex = 0
this.addText()
this.path = res.path
this.inited = true
}
})
}
},
mounted () {
wecropper = this.$refs.cropper
},
onLoad () {
ctx = wx.createCanvasContext('maker')
this.initImg()
},
onShareAppMessage () {
return {
title: !this.userText[0].txt ? '我发现了个斗图神器!' : this.userText[0].txt,
path: `/pages/index/main?id=${this.$root.$mp.query.id}&url=${this.$root.$mp.query.url}`
}
}
}
</script>
<style>
page {
height: 100%;
overflow: hidden;
}
.maker-container {
margin-top: 30rpx;
height: 100%;
}
.maker-header {
margin: 20rpx auto 10rpx;
/* margin-top: 20rpx; */
width: 600rpx;
}
.maker {
/* width: 300px; */
/* height: 300px; */
margin: 0 auto;
background-size: contain;
border: 1px solid #dddee1;
}
.maker-area {
width: 300px;
margin: 0 auto;
position: relative;
}
.input {
margin: 20rpx 0;
margin-right: 20rpx;
border: 1px solid #dddee1;
width: 100%;
}
.label {
font-size: 14px;
width: 80rpx;
display: inline-block;
line-height: 60rpx;
}
.input-number {
display: inline-block;
width: 100%;
}
.btn-area {
display: flex;
justify-content: space-between;
align-items: center;
}
.button {
flex: 1;
}
.divider-txt {
top: -10rpx;
position: relative;
}
.share-button {
margin-right: 20rpx;
}
.button button {
margin: 0;
}
.setting {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.save {
display: block;
width: 100%;
}
/* cropper */
.cropper-area {
position: fixed;
top: 0;
height: 100%;
left: 0;
z-index: 10;
}
.cropper-wrapper {
position: relative;
top: 0;
height: 100%;
background-color: #e5e5e5;
overflow: hidden;
}
.cropper-buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100rpx;
padding: 0 20rpx;
box-sizing: border-box;
line-height: 100rpx;
}
.cropper-buttons .upload,
.cropper-buttons .getCropperImage {
text-align: center;
}
.cropper {
position: absolute;
top: 0;
left: 0;
}
.cropper-buttons {
background-color: #090909;
}
.btn {
height: 60rpx;
line-height: 60rpx;
padding: 0 24rpx;
border-radius: 2px;
color: #ffffff;
margin-left: 10rpx;
}
</style>
数据库和服务端
使用小程序的云函数、云数据库,uni-app也有类似的服务;直接用node写,非常方便。
比如查询语句,就可以直接在客户端操作,语法类似于mongodb
:
this.$db.RegExp({ // 模糊查找(表情搜索)
regexp: '.*' + this.query.split('').join('.*') + '.*',
options: 'i'
})
要运行这个项目,你需要搭建一个云服务环境。
首先要在小程序后台新建云服务环境:
按照文档创建,建好服务。这里就不再赘述。
在小程序开发平台新建“表”(在小程序这里我们叫“集合”)。
新建两个集合:
feedback
: 用户反馈意见imageLink
:存放表情图片地址
数据插入
数据操作我们还用到了云函数,云函数有数据库操作权限。我们可以在云函数上把收集的数据、或者本地数据处理后好插入到小程序云数据库,比如:
const cloud = require('wx-server-sdk')
const path = require('path')
cloud.init({
env: 'develop-u9id2'
})
const db = cloud.database()
const sourceCol = db.collection('imageLink')
// 省略一大段
// 是否存在
async function get (desc, type) {
return await sourceCol.where({
desc,
type
}).get()
}
// 删除相关记录
async function remove (ids) {
if (ids.length <= 0) return true
return await sourceCol.where({
_id: db.command.in(ids)
}).remove()
}
// 上传文件信息到db
async function uploadImgInfo (fileInfo) {
return await sourceCol.add({
data: fileInfo
})
}
前端持久化
对于收藏表情,如果没做用户登录,我们可以使用本地存储进行:
if (!favImgs.some(item => data.id === item.id)) {
favImgs.push({id: data.id, url: data.url, type: this.fetchType})
wx.setStorageSync('fav_imgs', favImgs)
wx.showToast({
title: '收藏成功!',
icon: 'success',
duration: 1000
})
} else {
wx.showToast({
title: '该表情已收藏过!',
icon: 'none',
duration: 1000
})
}
以小程序作为实现平台的的好处是,你可以非常便捷的在微信传递刚做好的表情,并可以分享给别人这个制作工具。Have fun!