评论

如何使用painter实现一个海报编辑工具——以taro为例

如何使用painter实现一个海报编辑工具——以taro为例

文章开始前先做个简单的声明:这篇文章主要面向刚了解到 painter 的开发者,文中使用的框架、实现的方式只作为一种参考,并不一定是最佳实践。而使用 painter 能够做的扩展不止下文提到的这些能力,只是以下样例更为方便理解。欢迎各位酌情阅读。

自动态模版功能发布后,陆续有开发者开始尝试使用动态模版能力,我们也收集到了大家反馈的一些问题。这一系列文章的主要内容是从头开始实现一个简单的、基于 painter 动态模版能力的海报编辑工具。希望能通过这一过程,让大家了解为什么我们推出了动态模版能力,以及如何快速上手。同时在文中,也会统一回答一下关于动态模版使用的一些问题。文章中实现的编辑器代码,可以在https://github.com/Kujiale-Mobile/Taro-Painter-Demo/tree/2.x获取

先期准备

本次我们使用 2.2.15 版本的 taro 创建一个空项目

$ taro init

painter 组件是使用了 mina-painter 包(https://www.npmjs.com/package/mina-painter),这是我们封装的 taro 风格组件,供 taro 1.x/2.x 版本使用,支持 base64 图片与 canvas2d 模式。

$ yarn add mina-painter

创建空页面,并引入 painter 组件。

// pages/index/index.tsx
import Painter from 'mina-painter';
...
render() {
  return (
	...
	<Painter
         customStyle={`margin-top:5vh;`}
         customActionStyle={customActionStyle}
         dancePalette={danceTemplate}
         palette={outputTemplate}
         action={action}
         clearActionBox={clearActionBox}
         onImgOK={this.handleImgOk}
         onDidShow={this.handleDidShow}
         onTouchEnd={this.handleTouchEnd}
         onViewClicked={this.handleViewClick}
         onViewUpdate={this.handleViewUpdate}
       />
	...
  )
}

写一个简单的海报模版,包含 painter 内各种 view 类型。

// palette/index.ts
const template = {
  width: '750rpx',
  height: '1334rpx',
  background: '#FFFFFF',
  views: [
    {
      id: 'rect_10',
      type: 'rect',
      css: {
        scalable: true,
        color: '#F5F2EC',
        height: '348rpx',
        width: '750rpx',
        bottom: '0rpx',
        left: '0rpx',
        minWidth: '80rpx',
        minHeight: '80rpx',
      },
    },
    {
      id: 'rect_9',
      type: 'rect',
      css: {
        scalable: true,
        color: '#CBBD9F',
        height: '646rpx',
        width: '388rpx',
        left: '0rpx',
        top: '456rpx',
        minWidth: '80rpx',
        minHeight: '80rpx',
      },
    },
    {
      id: 'rect_8',
      type: 'rect',
      css: {
        scalable: true,
        color: '#EBE5D7',
        height: '160rpx',
        width: '360rpx',
        top: '222rpx',
        right: '0rpx',
        minWidth: '80rpx',
        minHeight: '80rpx',
      },
    },
    {
      id: 'qrcode',
      type: 'image',
      url: 'https://qhstaticssl.kujiale.com/newt/100082/image/jpeg/1623053391518/3EF9BB7ABE024959EB2A0E81078B40FA.jpeg',
      css: {
        width: '202rpx',
        height: '202rpx',
        bottom: '76rpx',
        right: '40rpx',
        borderRadius: '8rpx',
        borderColor: '#FFFFFF',
        borderWidth: '4rpx',
      },
    },
    {
      id: 'worker_type',
      type: 'text',
      text: '门店店长',
      css: {
        scalable: true,
        deletable: true,
        left: '156rpx',
        bottom: '76rpx',
        fontSize: '24rpx',
        color: '#656c75',
        lineHeight: '34rpx',
      },
    },
    {
      id: 'worker_name',
      type: 'text',
      text: 'tester',
      css: {
        scalable: true,
        deletable: true,
        fontSize: '30rpx',
        fontWeight: 'bold',
        color: '#333',
        left: '156rpx',
        bottom: '114rpx',
        width: '280rpx',
        lineHeight: '42rpx',
        maxLines: 1,
      },
    },
    {
      id: 'avatar',
      type: 'image',
      url: 'https://qhstaticssl.kujiale.com/newt/100082/image/png/1623053110600/BDA064C5ECDCB7DD50DEB466C70E2EB0.png',
      css: {
        width: '80rpx',
        height: '80rpx',
        borderRadius: '40rpx',
        left: '52rpx',
        bottom: '76rpx',
      },
    },
    {
      type: 'image',
      id: 'image-main',
      url: 'https://qhstaticssl.kujiale.com/newt/100082/image/jpeg/1623053489433/54EE335A9C385A3D99D8664CB9135F84.jpg',
      css: {
        width: '672rpx',
        height: '672rpx',
        mode: 'aspectFill',
        right: '0rpx',
        top: '314rpx',
        scalable: true,
        minWidth: '120rpx',
      },
    },
    {
      type: 'rect',
      css: {
        width: '666rpx',
        height: '2rpx',
        top: '144rpx',
        right: '0rpx',
        color: '#EBEFF5',
      },
    },
    {
      id: 'name',
      type: 'text',
      text: '一个蒙着红色布的——球?',
      css: {
        scalable: true,
        deletable: true,
        fontSize: '32rpx',
        color: '#383c42',
        maxLines: 1,
        width: '480rpx',
        left: '76rpx',
        top: '74rpx',
        lineHeight: '44rpx',
      },
    },
    {
      id: 'product',
      type: 'text',
      text: '¥9999',
      css: {
        scalable: true,
        deletable: true,
        fontSize: '80rpx',
        lineHeight: '90rpx',
        fontWeight: 'bold',
        color: '#383C42',
        textAlign: 'center',
        left: '76rpx',
        top: '170rpx',
      },
    },
  ],
}

以上种种准备好之后,我们就能得到这样的一个页面:

这个页面有最基础的点选、拖动能力,通过配置 view.css 的 scalable 和 deletable 属性,可以使用 painter 内置提供的缩放功能。
怎么样,是不是已经功能很完备,好像可以满足需求了啊~好,今天的分享就到此为止(并不是)

接下来,我们主要会为 text 、image 提供一些能力拓展,并实现基本的撤销、恢复功能。能力拓展的方式是相似的,相信在看完文章后,你就可以熟练地为任意 view 类型拓展能力了。

通过刷新整个 palette 方式进行的操作

// pages/index/index.tsx
refreshPalette = (palette?: IPalette) => {
  this.setState({
    dancePalette: palette || { ...this.currentPalette },
  });
};

最简单的刷新海报的方式就是直接刷新整个 palette 了,这种操作即便是不使用动态模版也一样可以用。这种方式开销大,速度相对慢,但是可以完全改变海报的结构

删除 View

虽然 painter 提供了自定义删除 icon 的方法,但是点击删除按钮,你会发现 view 并没有被删除。这是因为我们希望这种修改 palette 的操作能够让外部主动操作,而不是将删除操作也内置——那可能会导致你对自己写的海报模版失去掌控。要想实现删除逻辑非常简单,当用户点击删除按钮时,我们可以从 onTouchEnd 处监听到一个 type = ‘delete’ 的事件。

从 palette 中找出对应的 view 并删除,然后更新 palette 就能完成删除操作了。

// pages/index/index.tsx
this.currentPalette.views.splice(detail.index, 1);
this.refreshPalette();

修改背景

修改背景更为简单——直接改 palette 的 background 属性,然后刷新模版即可

// pages/index/index.tsx
this.currentPalette.background = color;
this.refreshPalette();

添加新 View —— 以 text 为例

准备一个预先定义好样式的 text 类型的 view ,将输入内容填充后塞入模版的 views 中,最后刷新模版即可

// common/index.ts
export function getBlankTextView(text?: string): IView {
  return {
    type: 'text',
    text: text || '',
    id: `text_${new Date().getTime()}${Math.ceil(Math.random() * 10)}`,
    css: {
      scalable: true,
      deletable: true,
      width: '384rpx',
      fontSize: '36rpx',
      color: '#000',
      textAlign: 'center',
      padding: '0 8rpx 8rpx 8rpx',
      top: '50%',
      left: '50%',
      align: 'center',
      verticalAlign: 'center',
    },
  };
}

// pages/index/index.tsx
this.currentPalette.views.push(getBlankTextView(inputValue));
this.refreshPalette();

通过刷新 action 方式进行的操作

// pages/index/index.tsx
refreshSelectView = (view?: IView) => {
  this.setState({
    action: { view: view || this.currentView },
  });
};

painter 动态模版功能的一大改动就是增加了 action 属性。当我们向 action 传入一个 view ,painter 会去寻找与其匹配的 view 并刷新状态。通过这种方式,我们最小化了需要修改的内容,从而减少了 painter 所需要的渲染时间。

刷新选中view的样式——以 text 为例

通过监听 onViewClick 事件,我们能够获取当前点击的 view ,在确定当前 view 后,我们就可以通过修改改 view 的 css ,然后刷新 action 来修改样式了。具体表现如下:

// pages/index/index.tsx
this.currentView.css = newCss;
this.refreshSelectView();

除了 text ,其他各类 view 也都可以做类似操作,比如修改 rect 的尺寸、修改图片链接、基于替换图片链接实现图片裁剪等等。这里只是抛砖引玉,欢迎大家向我们分享你做出了什么炫酷的功能。

同时使用上述两种方法实现撤销与恢复操作

上面介绍了两种刷新海报的方式,而接下来,我们实现一个简单的撤销与恢复功能。这个功能的核心没有什么特殊的,就是同时维持撤销栈和恢复栈两个栈,通过记录之前所做的操作,做反向操作。

// pages/index/index.tsx
interface ITimeStackItem {
  view?: IView;
  palette?: IPalette;
  index?: number;
  type?: string;
}

pushToHistory = (item: ITimeStackItem) => {
  this.future.length = 0;
  while (this.history.length > 19) {
    this.history.shift();
  }
  this.history.push(item);
  this.refreshTop();
};


handleTimeMachine = (type: 'revert' | 'recover') => {
  let popStack: ITimeStackItem[];
  let pushStack: ITimeStackItem[];
  if (type === 'revert') {
    popStack = this.history;
    pushStack = this.future;
  } else {
    pushStack = this.history;
    popStack = this.future;
  }
  const pre = popStack.pop();
  if (!pre) {
    return;
  }
  if (pre.type === 'delete') {
    this.currentView = undefined;
    if (this.currentPalette.views[pre.index!] && this.currentPalette.views[pre.index!].id === pre.view!.id) {
      this.currentPalette.views.splice(pre.index!, 1);
    } else {
      this.currentPalette.views.splice(pre.index!, 0, pre.view!);
    }
    pushStack.push(pre);
    this.refreshPalette();
  } else if (pre.palette) {
    pushStack.push({
      palette: JSON.parse(JSON.stringify(this.currentPalette)),
    });
    this.currentPalette = pre.palette;
    this.currentView = undefined;
    this.refreshPalette();
  } else {
    for (let i = 0; i < this.currentPalette.views.length; i++) {
      if (this.currentPalette.views[i].id === pre.view!.id) {
        pushStack.push({
          view: JSON.parse(JSON.stringify(this.currentPalette.views[i])),
        });
        this.currentPalette.views[i] = pre.view!;
        this.currentView = this.currentPalette.views[i];
        this.refreshSelectView(pre.view);
        break;
      }
    }
  }
  this.setState({
    editState: this.currentView && this.currentView.type === 'text' ? EditState.TEXT : EditState.NORMAL,
  });
  this.refreshTop();
};

保存生成的海报

在操作动态模版时,是不会触发 onImgOk 的,因为动态模版的内容渲染在四个不同层级的 canvas 上,无法实时生成完善的海报图片,所以需要手动设置 palette 使用静态模版生成对应的海报

// pages/index/index.tsx
this.setState({
  palette: JSON.parse(JSON.stringify(this.currentPalette)),
});

handleImgOk = path => {
  ...
};

总结

经过上述的一个流程,是不是对如何使用 painter 的动态模版有一些新的想法了呢?欢迎大家基于 painter 开发出更多有趣的功能并在评论区与我们分享。

最后一次编辑于  2021-06-16  
点赞 4
收藏
评论

2 个评论

  • 晴空万里
    晴空万里
    2023-04-17

    你的网络图片可以画不出来么?需要什么地方设置么?

    2023-04-17
    赞同
    回复
  • ネф イω
    ネф イω
    2021-06-19

    辛苦了,有点难

    2021-06-19
    赞同
    回复
登录 后发表内容