评论

#小程序云开发挑战赛#-抽屉表情-为了获得T恤

小程序云开发挑战赛参数作品: 抽屉表情。 来自“为了获得T恤”队。

应用场景

我在跟朋友聊天,灵感来了

“我有个很应景的表情包,这时候我要是发出去的话,肯定很有意思”

于是我打开微信表情寻找,划呀划划呀划……过了大概30秒,我突然感觉有点头疼,这种感觉就像我今天下午跟女朋友玩1000张的拼图,里面的天空是一望无际的蓝色一样,让人奔溃。图还没找到,可是我们的话题已经来到了另一个世界。之前想找的那张表情就像我冰箱里龙眼,放了太久,还没拿出来吃就烂了。

有时候我知道表情包上写着什么,可是却没有办法搜索。

我手机相册里有从各种app上收集来的几百张表情图,每次想要发送一张,我就得在几百张图片中来回寻找,可谓众里寻他千某度。

生活已经很累了,就不要再徒增烦恼了。

所以就有了今天这个小程序:抽屉表情;就像名字一样,你想要的表情就在这个抽屉里,打开就能看到。

主要功能

上传表情图,管理你手机里的表情包。

给表情添加标签,通过标签快速搜索。

使用表情模板功能,超简单三步,表情配图并发送。

目标用户

我,以及跟我一样有上面烦恼的同学。

实现思路

为了达到低成本,高效率开发,服务端使用了小程序云函数三件套:云函数,数据库,云存储。

小程序端使用了小程序兼容层框架 Taro,使用前端流行的 react 框架开发。其特点是高效便捷。

使用了typescript增加代码质量与可维护性,使用了mobx作为状态管理工具,实现了更好的页面组件的数据流通信。

架构图

效果截图

功能代码展示

import { observable, action } from 'mobx'
import { TemplateIndexData } from 'src/types'

/**
 * 表情制作模块 store
 */
export class MakeStore {
    /**
     * 首页数据
     */
    @observable indexData: TemplateIndexData[] = []

    /**
     * 设置首页的数据
     */
    @action.bound
    setIndexData(data) {
        this.indexData = data
    }

    /**
     * 存储表情模板集合的map
     * 用户缓存表情模板集合的数据,避免接口重复加载
     */
    @observable collectionMap: Map = new Map()

    /**
     * 存储表情模板的map
     */
    @observable templateMap: Map = new Map()

    /**
     * 设置模板集合
     */
    @action.bound
    setCollection(data) {
        this.collectionMap.set(data._id, data)
        const { templates = [] } = data
        templates.forEach(template => {
            this.templateMap.set(template._id, template)
        });
    }

    /**
     * 获取单个模板集合
     */
    @action.bound
    getCollection(id) {
        return this.collectionMap.get(id)
    }

    /**
     * 获取单个模板数据
     */
    @action.bound
    getTemplate(id) {
        return this.templateMap.get(id)
    }
}

const makeStore = new MakeStore()

export default makeStore


import React, { Component } from 'react'
import { View, Canvas, Textarea, Button, Input } from '@tarojs/components'
import Taro, { getImageInfo } from '@tarojs/taro'
import './edit.scss';
import { inject, observer } from 'mobx-react';
import { MemeTemplate, Store } from 'src/types';

type PageProps = {
  store: Store
}

type PageState = {
  loading: boolean,
  canvasStyle: {
    width: number,
    height: number
  },
  /** 表情模板 */
  template: MemeTemplate,
  text?: string,
}

@inject('store')
@observer
class MakeEdit extends Component {

  /** 开发模式,显示文字框与调试框 */
  dev = false

  ctx: Taro.CanvasContext

  imgRatio = 1

  image: getImageInfo.SuccessCallbackResult

  /** 每1px(非物理像素) 等于多少 rpx */
  pxOfRpx = 1

  state: PageState = {
    loading: false,
    canvasStyle: {
      width: 690,
      height: 460
    },
    template: null as any
  }

  /**
   * 由于图片显示时有可能进行缩放
   * 所以需要计算出缩放后的文本框的位置与大小
   */
  get computedTextArea() {
    const { x, y, w, h } = this.state.template.textArea
    const result = {
      x: x * this.imgRatio,
      y: y * this.imgRatio,
      w: w * this.imgRatio,
      h: h * this.imgRatio,
    }
    return result
  }

  async loadImg() {
    this.setState({ loading: true })
    Taro.showLoading({
      title: '加载中',
      mask: true
    })
    const res = await Taro.getImageInfo({ src: this.state.template.url })
    this.setState({ loading: false })
    Taro.hideLoading()
    return res
  }

  async onLoad(option) {
    const template = this.props.store.make.getTemplate(option.id)
    this.setState({ template })
    //由于setState 不会马上执行,需要等待 setState 执行完成
    await Promise.resolve()

    const res = await this.loadImg()
    const { screenWidth } = Taro.getSystemInfoSync()

    /** 每1px 有多少 rpx */
    this.pxOfRpx = 750 / screenWidth
    this.image = res
    this.ctx = Taro.createCanvasContext('canvas')


    this.initCanvas()
    this.drawContent()
  }

  initCanvas() {
    const maxWidth = 690;
    const maxHeight = 690;
    const width = this.image.width * this.pxOfRpx
    const height = this.image.height * this.pxOfRpx

    /** 最大宽高与图片宽高的最小比值 */
    const ratio = Math.min(maxWidth / width, maxHeight / height)
    /** 图片需要缩放的比例,图片宽高没有超过最大宽高的话,不需要缩放 */
    this.imgRatio = Math.min(ratio, 1)

    this.setState({
      canvasStyle: {
        width: width * this.imgRatio,
        height: height * this.imgRatio,
      }
    })
  }

  /**
   * dev:绘制文本框
   */
  drawRect() {
    const ctx = this.ctx
    ctx.setStrokeStyle('red')
    const { x, y, w, h } = this.computedTextArea
    ctx.strokeRect(x, y, w, h)
    ctx.draw(true)
  }

  /**
   * 绘制文本
   * 根据文本的长度,自动计算出字体大小。简化用户操作
   */
  drawText(lines) {
    if (!lines.length) return

    const textStyle = this.state.template.textStyle
    const textColor = textStyle?.color || 'black'
    let maxFontSize = textStyle?.maxFontSize || 50


    const ctx = this.ctx
    ctx.setTextBaseline("top")
    ctx.setTextAlign("center")
    ctx.setFillStyle(textColor)
    const { x, y, w, h } = this.computedTextArea
    let fontSize = maxFontSize

    ctx.setFontSize(fontSize)

    //计算出长度最长那行的值
    const longestValue = lines.map(text => ctx.measureText(text).width).sort((a, b) => b - a)[0]


    //文字行数 * 行高不能超过文本框高度,计算出最大字体大小
    maxFontSize = Math.min(maxFontSize, h / lines.length / 1.2)
    //根据比例,计算出最合适的字体大小
    fontSize = Math.min(maxFontSize, w / longestValue * fontSize)
    ctx.setFontSize(fontSize)

    //逐行绘制
    const lineHeight = fontSize * 1.2
    for (const [index, text] of lines.entries()) {
      ctx.fillText(text, x + w / 2, y + lineHeight * index)
    }
    ctx.draw(true)
  }

  async drawImg() {
    const ctx = this.ctx
    const { width, height } = this.state.canvasStyle
    ctx.drawImage(this.image.path, 00, width / this.pxOfRpx, height / this.pxOfRpx)
    ctx.draw(true)
  }

  /**
   * 绘制所有内容
   */
  drawContent() {
    this.ctx.draw(false)
    const text = this.state.text || ''
    const lines = text.split('\n').filter(item => item).map(item => item.trim())
    this.drawImg()
    this.dev && this.drawRect()
    this.drawText(lines)
  }

  /**
   * 文本变化时绘制内容
   */
  handleTextChange = e => {
    this.setState({
      text: e.target.value
    })
    this.drawContent()
  }

  /**
   * 将canvas上的内容生成临时文件,并且预览
   */
  previewImg() {
    const ctx = this.ctx
    //真机上如果没有操作,会无法draw成功
    ctx.setStrokeStyle('red')
    ctx.draw(true() => {
      Taro.canvasToTempFilePath({
        canvasId: 'canvas',
        success: function (res{
          Taro.previewImage({
            urls: [res.tempFilePath]
          })
        },
      })
    });
  }

  handleComplete = async () => {
    this.previewImg()
  }
  handleCancel = () => {
    Taro.navigateBack()
  }

  /**
   * 开发时,调试文本框与文字样式
   */
  handleDevInput = (e) => {
    const value: string = e.target.value
    const [x, y, w, h, fz, color] = value.split(',')
    if (value.split(',').some(item => !item)) {
      return
    }
    this.setState({
      template: {
        ...this.state.template,
        textArea: { x: +x, y: +y, w: +w, h: +h },
        textStyle: {
          maxFontSize: +fz,
          color
        }
      }
    })

    console.log(`设置文本框`);
    console.log(JSON.stringify({
      id: this.state.template._id,
      ...this.state.template
    }));
    this.drawContent()
  }

  render() {
    return (
      
        
          {!this.state.loading &&
            
          }
       
          {/* *点击完成后,长按图片分享给朋友哦! */}
        {
          this.dev && this.state.template?.textArea && (
              x,y,w,h,fz,color
          )
        }
          完成
          取消
    )
  }
}

export default MakeEdit


团队简介

为了获得T恤队,口号是:不忘初心

最后一次编辑于  09-20  
点赞 93
收藏
评论

8 个评论

  • 嘉
    09-21

    哦豁?

    09-21
    赞同 3
    回复
  • N
    N
    09-21

    不明觉厉,支持下

    09-21
    赞同 3
    回复
  • 吃恩沉
    吃恩沉
    09-21
    就很厉害(头发要紧)
    09-21
    赞同 2
    回复
  • 沵可安好°
    沵可安好°
    09-21
    有创意
    09-21
    赞同 2
    回复
  • 高峰
    高峰
    09-21

    很不错,支持一下

    09-21
    赞同 2
    回复
  • PGwhy
    PGwhy
    09-21
    膜拜大神
    09-21
    赞同 1
    回复
  • George·Ds
    George·Ds
    09-21
    非常棒👍
    09-21
    赞同 1
    回复
  • 青寒
    青寒
    09-22

    喜欢这些工具类的。

    09-22
    赞同
    回复 1
登录 后发表内容