论“斗图”文化兴起和分享一个表情包小程序代码

简介: 论“斗图”文化兴起和分享一个表情包小程序代码

代码地址:https://github.com/doterlin/emote-maker-mp

大约三四年前,斗图文化兴起,顿时聊天氛围就变得生动多彩,生龙活虎,其乐融融。

人们不再满足于文字交流,语音和视频又因自身腼腆原因不能完全表达意境,所以图片成了最佳表达自身情感的最佳媒介。

那些聊天软件内置的表情又生硬又不够丰富,显得比较刻板,看了十几年“小黄头”也该腻了;老年人式的表情包也显得大家“土”气,表情包“革命”蓄势待发。

image.png

image.png

接着是部分IP表情包开始兴起,以及一些网络红人也陆续被制作成表情,以及早就在贴吧论坛玩过的那些图也用到聊天软件,越来越多人发现在表达上这些图片表情更加出众:

“啊,这玩意真带劲!”

阿狸表情

火出圈的宋民国小朋友

image.png

但是,带劲归带劲,社交最主要的是互动

表情总不能你发你的,我发我的,表情之前没有关联性,也无对话可言,那社交软件不就很没面子。

于是慢慢在这些表情针对一些常用对话加到表情包,开始形成一些对话表情包,甚至是系列表情包。

image.png

image.png

image.png

这些表情包的出现使得斗图一下子氛围变得非常活跃,并巩固了“熊猫”、“蘑菇头”成为表情包标准“包装”的地位。

张学友D'Angelo Dinero都想不到自己能以这种方式火了。

image.png

image.png

能快速自定义表情文字的需求也就慢慢逐渐变多了,于是程序猿们就造了一些表情制作器给大家用,把一些经典表情和“熊猫”、“蘑菇头”这些表情加上一些自己的文字,就能让表情包说出自己想说的话。

我三四年前也做了一个,现在突然翻到了代码,可以看到技术还是比较老,但还是想分享下。挂着几年也就几K用户。

image.png

image.png

技术栈

代码地址:https://github.com/doterlin/emote-maker-mp

体验二维码:

image.png

核心实现

表情制作

表情制作的核心是使用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'
        })

要运行这个项目,你需要搭建一个云服务环境。
首先要在小程序后台新建云服务环境:

image.png
按照文档创建,建好服务。这里就不再赘述。

在小程序开发平台新建“表”(在小程序这里我们叫“集合”)。

image.png

新建两个集合:

  • feedback: 用户反馈意见
  • imageLink:存放表情图片地址

image.png

数据插入

数据操作我们还用到了云函数,云函数有数据库操作权限。我们可以在云函数上把收集的数据、或者本地数据处理后好插入到小程序云数据库,比如:

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!

代码地址:https://github.com/doterlin/emote-maker-mp

image.png

相关文章
|
2月前
|
小程序 JavaScript 前端开发
uni-app开发微信小程序:四大解决方案,轻松应对主包与vendor.js过大打包难题
uni-app开发微信小程序:四大解决方案,轻松应对主包与vendor.js过大打包难题
742 1
|
12天前
|
人工智能 小程序 JavaScript
【一步步开发AI运动小程序】十四、主包超出2M大小限制,如何将插件分包发布?
本文介绍了如何从零开始开发一个AI运动小程序,重点讲解了通过分包技术解决程序包超过2M限制的问题。详细步骤包括在uni-app中创建分包、配置`manifest.json`和`pages.json`文件,并提供了分包前后代码大小对比,帮助开发者高效实现AI运动功能。
|
6月前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp小程序的箱包存储系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp小程序的箱包存储系统附带文章源码部署视频讲解等
50 5
|
2月前
|
开发框架 小程序 JavaScript
小程序代码丢失!反编译找回
小程序源代码的容易获取问题确实存在一些潜在的安全隐患。然而,现在的小程序开发框架采用像 Babel 这样的打包工具,将 JavaScript 逻辑代码混合在一个文件中并进行转编译,使其变得难以理解。
55 0
小程序代码丢失!反编译找回
|
7月前
|
JavaScript Java 测试技术
基于小程序的移动学习平台+springboot+vue.js附带文章和源代码说明文档ppt
基于小程序的移动学习平台+springboot+vue.js附带文章和源代码说明文档ppt
50 0
|
4月前
|
存储 小程序 Java
【小程序分包】小程序包大于2M,来这教你分包啊
本文介绍了如何通过分包解决uniapp小程序包体积过大的问题。由于版本升级导致包体积超过2M,即使压缩静态资源也无法满足发布要求。文章详细讲解了分包的原因、步骤及注意事项,并提供了实操示例,帮助读者理解并实现小程序分包,从而减小主包大小
216 1
【小程序分包】小程序包大于2M,来这教你分包啊
|
3月前
|
小程序 JavaScript Go
代码总有一个是你想要的分享63个微信小程序源
分享63个微信小程序源代码,包括电商系统、同城拼车、博客等多种应用,涵盖C#、Node.js、Golang等技术栈。每个项目附带源码和示例,适合初学者和开发者参考学习。提取码:8888,代码效果参考:http://www.603393.com/sitemap.xml。
79 2
|
4月前
|
小程序 前端开发 JavaScript
微信小程序实现微信支付(代码和注释很详细)
微信小程序实现微信支付(代码和注释很详细)
|
4月前
|
小程序 JavaScript 前端开发
微信小程序开发必备前置知识:基本代码构成与语法
【8月更文挑战第8天】微信小程序的基本代码构成与语法
113 0
微信小程序开发必备前置知识:基本代码构成与语法
|
4月前
|
小程序 JavaScript 安全
微信小程序实现云闪付支付(代码和注释很详细)
微信小程序实现云闪付支付(代码和注释很详细)
下一篇
DataWorks