跳到主要内容

将编辑器搬进小程序的一种方法

· 阅读需 10 分钟

某一天开评审会,我瞄到了下图:

效果图

在看到稿子上有这个东西的时候我就心里发怵,不会真要在小程序里写这个编辑器吧😰。在上面的图中除了文本输入功能之外,还能插入一些预定义的占位变量。

探索之旅

在得知需求无法变更之后赶紧一头扎进微信开发文档,寻找解决方案。

官方editor组件实现

首先我找到了editor组件,根据EditorContext的API,我又分别尝试了两种方法。

利用setContents实现

原理就是先将编辑器中原来的内容取出来,再将占位变量加进去之后调用setContents接口将完整内容放置进去。

但是很快发现这个方法不可行,因为无法在内容中间插入变量,没有API支持获取当前光标位置。

利用insertImage实现

看到插入图片的时候就在想,不妨将每个变量作为一张图片试试,点击变量的时候直接插入图片岂不妙哉!理论上已经非常完美了。

<button size="mini" bind:tap="handleInsertTag">变量</button>

<editor id="editor" />
handleInsertTag() {
wx.createSelectorQuery().select('#editor').context(function(res){
console.log(res.context) // 节点对应的 Context 对象。如:选中的节点是 <video> 组件,那么此处即返回 VideoContext 对象

res.context.insertImage({
src: 'https://s.xxx.com/xxx.png',
width: 50,
height: 22,
success: () => {
res.context.getContents({
success: ({ delta, html }) => {
console.log(delta);
}
})
}
})
}).exec()

来看效果演示:

insertImage实现

正当我以为这把肯定稳了的时候,它带着幺蛾子来了,在我全选内容之后发现了不妙:

insertImage实现

后面多了几个空行,这就比较难受了,虽然在变量追加在内容末尾的时候可以通过取出内容再去掉空行来解决,但是如果在内容中间插入就会出现断行了,方案再次pass,或者说官方editor整个都被pass了。

终极方案

看来看去似乎从文档里是找不到方向了,放在网页里还比较好实现,小程序确实是没辙了,毕竟微信提供的组件就那么几个总不能自己扩展吧。刚说了什么?网页?不是有web-view组件吗?再次去扒文档:

文档1 文档2

看到网页和小程序可以通信,网页也可以控制小程序路由,大概觉得应该是有希望实现的,于是就思路朝这个方向去发展:

  • 首先需要一个网页实现编辑功能;
  • 由于文档提到web-view会占满全屏,所以务必要将其放在一个单独页面中;
  • 既然是编辑器页面,肯定会有原来的数据,没看到小程序怎么向网页发消息,那么就通过url传递(仅限于数据量不大的情况);
  • 在网页编辑完之后通过 postMessage 发送消息,然后再调用 navigateBack 退出编辑页面返回前一页面,同时小程序侧监听 bindmessage 获取数据并存储流程结束。

这么看起来似乎是可以走通了,那么从上面可以看出来我们再跳转到编辑器之前是需要一个前置页面的,那么大体就是这样了,准备开搞。

<web-view
wx:if="{{ editorUrl }}"
src="{{ editorUrl }}"
bindmessage="handleMessage"
/>
onLoad: function (options) {
const editorLink = 'https://xxx.com/editor.html'
const oldValue = ''
this.setData({
editorUrl: `${editorLink}#wechat_redirect?t=${Date.now()}&value=${oldValue || ''}`
})
},

handleMessage(e) {
console.log(e)
}

上面就是小程序部分的内容了,其中editorLink是编辑器网页的地址,该地址需要添加到微信管理后台handleMessage用于接受网页通信,下面就是编辑器部分了,不过这里不展开讲编辑器本身的实现(这块本身也可以涉及到很深的内容),那么下面开始表演:

<!-- 错误提示框 -->
<div id="error" class="error">内容长度不能超过80字</div>

<!--参与人数及次数通知变量-->
<div id="tag_wrap" class="tag-wrap" unselectable="on">
<span class="label">选择变量:</span>
<span class="tag" onclick="handleInsertTag('参与人数')">参与人数</span>
<span class="tag" onclick="handleInsertTag('抽奖次数')">抽奖次数</span>
</div>

<div id="editor" class="editor" contenteditable="true"></div>
<button class="save" onclick="handleSave()">保存</button>

<script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.3.2.js"></script>

那么花了点时间我们把界面写好了,这里省略了样式,因为比较简单,稍微美化了一下,形如下图:

界面

下面就是编辑器里面比较重要的 js 了。

var editorDomId = 'editor';
var editorDom = document.getElementById(editorDomId);

// 禁用变量区域的focus事件
// 防止出现点击了变量之后从输入框失焦,导致一直将变量追加在最后
document.getElementById('tag_wrap').addEventListener('mousedown', function (e) {
e.stopPropagation();
e.preventDefault();
return false;
})

// 获取 URL 参数
function getQueryString(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
var r = window.location.search.substr(1).match(reg);
if (r !== null) {
return decodeURIComponent(r[2]);
}
return null;
}

// 初始化编辑框内容
function initData () {
var value = getQueryString('value');
if (!value) return;
// 将 {{}} 包裹的变量用特殊标签包裹起来
var html = decodeURIComponent(value).replace(/\{\{(.+?)\}\}/gmi, '<section class="tag-in-editor" unselectable="on" onmousedown="return false" contentEditable="false">$1</section><span></span>');
editorDom.innerHTML = html;
}
initData();

// 插入变量
function handleInsertTag (tag) {
var tagHtml = '<section class="tag-in-editor" unselectable="on" onmousedown="return false" contentEditable="false">' + tag + '</section><span></span>';

var activeDomId = document.activeElement.id;
// 如果当前焦点不在输入框,直接在后面追加
if (activeDomId !== editorDomId) {
editorDom.innerHTML = editorDom.innerHTML + tagHtml;
return;
}

if (window.getSelection) {
// 定位光标位置
var selection = window.getSelection();
if (selection.getRangeAt && selection.rangeCount) {
var range = selection.getRangeAt(0);
range.deleteContents();
var el = document.createElement('div')
el.innerHTML = tagHtml;
var frag = document.createDocumentFragment();
var node, lastNode;
while ((node = el.firstChild)) {
lastNode = frag.appendChild(node);
}
range.insertNode(frag);
if (lastNode) {
range = range.cloneRange();
range.setStartAfter(lastNode);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
} else if (document.selection && document.selection.type !== 'Control') {
document.selection.createRange().pasteHTML(tagHtml);
}
}

var timer = null;
// 保存内容
function handleSave() {
// 替换变量标签
var html = editorDom.innerHTML
.replace(/(<section[^>]*?>)/gi, '{{')
.replace(/(<\/section>)/gi, '}}');

// 这里还要对 html 做一下过滤为纯文本
const valueNode = document.createElement('div');
valueNode.innerHTML = html;

var value = valueNode.innerText.replace(/\n/gi, '');

if (!value || !value.length) {
clearTimeout(timer)
document.getElementById('error').innerText = '通知内容不能为空'
document.getElementById('error').setAttribute('style', 'display: block;');
timer = setTimeout(function() {
document.getElementById('error').removeAttribute('style');
}, 2000);
return
}

wx.miniProgram.postMessage({ data: value });
wx.miniProgram.navigateBack();
}

那么提前说一下我这里定义了一种变量的格式,在一长串文本中遇到:{{xx}} 双重花括号包裹起来的 xx 就会被认为是变量。

然后稍微讲一下这里各部分做了什么:

  • getQueryString 函数:获取 url 中的参数;
  • initData 函数:根据 url的 value 值初始化编辑器内容;
  • handleInsertTag 函数:在指定位置插入变量;
  • handleSave 函数:将内容传递给小程序,并返回上一页面。

然后我们将这个 htmljs 都放到一起单独拎出来传到服务器/CDN或者其他什么地方,并把链接的域名配置到微信后台业务域名,最后把链接内嵌到小程序的 web-view 标签中。

内嵌到web-view中

花了点时间,配置一下启动参数,这里我省略了 encode 步骤:

参数配置

发现并没有初始化成功,内容没出现,但是呢大家直到微信开发者工具指不定什么情况下会抽风,既然我们理论没错,上手机扫码试试:

真机演示

效果似乎还可以,那么到这里重头戏已经完成了,剩下的数据通信比较简单,我这里打印一下做一下示意:

demo

到目前为止功能已经完全实现了,在思路明确之后,其他方面已经变得很清晰,反而编辑器本身变成了难点,由于是内嵌在小程序的页面,没有加载任何框架(也不敢用😂),毕竟在这种环境尽量减少外部依赖才能最稳定,后续优化也将重点放在编辑器上。

OK关于这部分的内容已经聊完了,最近一直在做小程序,后续也许有其他好点子再来探讨一下。