有道写作浏览器扩展实践

有道写作浏览器扩展作为一款为网页增加英文语法批改的辅助工具,允许用户在任意网页上绝大部分的富文本编辑器、多行文本输入框中编辑英文文本,可实时得到批改结果反馈,并自行接受建议自动修改,实现完美写作。
来源/ 有道技术团队公众号
作者/ 李靖雯
编辑/ 刘振宇
一、背景介绍
有道写作服务是有道出品的写作智能批改产品,为用户提供优质的作文拼写、语法、样式方面的批改服务。有道写作不仅仅支持浏览器扩展形式,还支持在其他平台使用:例如有道词典 APP-作文批改、Web 在线端、Word 插件、PC 词典内。欢迎各位体验。
浏览器插件在浏览器里面的称呼是 Browser Extension,也就是浏览器扩展,是一个扩展网页浏览器功能的插件。它主要基于 HTML、JavaScript、CSS 开发,同时由于是扩展特性,可以利用浏览器本身提供的底层 API 接口进行开发,可以给所用页面或者特定页面添加一些特殊功能而不影响原本页面逻辑。
每个支持扩展的浏览器有自己下载扩展的应用商店,可以直接在应用商店下载。有些产品自己提供浏览器扩展的 .crx 文件让用户下载并安装。
二、适配浏览器
有道写作在 Windows/Mac 系统都可安装,适配 Chrome、360 安全浏览器、360 极速浏览器、Edge 新版浏览器等,在以上浏览器商店中搜索有道写作,点击安装按钮即可。

三、功能介绍 &效果展示
在介绍开发思路与实践之前,我们先来直观地看一下有道写作浏览器扩展的实际效果,并对其功能进行简单的介绍。
3.1 表现方式
视觉效果就是,给错误的文本字符下面画一条横线,在 hover 的时候,可以给文本增加一个高亮的效果。在选接受建议的时候,可以替换成我们想要的文本数据。

3.2 适用场景
>>> 在线邮件编辑:

163 邮箱

Outlook 邮箱

Gmail
>>> 社交动态、评论:


微博动态

评论
>>> 工具、笔记类:

有道翻译

Google 翻译

石墨文档
3.3 功能介绍
>>> 实时批改:
支持一边修改一边实时提供批改反馈,展示批改错误数量。

>>> 语法检测:

>>> 增强编辑框:
可以查看每一个错误反馈详细内容,并可分错误类型过滤查看结果。

>>> 接受建议:
点击接受建议时候替换正确文本。

四、开发思路
需求:扩展需要针对页面上的可输入文本的编辑框赋予批改的功能
4.1 适配编辑器
那么,网页中可输入文本的编辑框都有哪些呢?
通常我们常见可输入编辑框有:
基于 Web 的表单可以输入文本控件:input、textarea
可编辑属性的元素:contenteditable
Input 元素通常是一行且输入范围较短的内容,考虑到批改交互的功能,我们的扩展针对以下可输入较多文本的编辑器进行兼容:
contenteditable 富文本编辑器
textarea
其他文档编辑器

4.2 富文本编辑器
我们常见基于 contenteditable 实现的富文本编辑器有百度编辑器、draft.js、 有道云笔记(旧版)等等。
相比 textarea,富文本编辑器可以包含很多不同标签,可以以用来渲染成不同字体颜色的文本、图片、附件、视频、音频等等元素。

实现基于浏览器的富文本编辑器的四要素

四代编辑器的技术选型
第一代编辑器主要是通过有限的 execCommand 指令对 html 文档进行操作。
第二代则是在 execCommand 基础上,添加更多自定义指令甚至自己实现指令方式修改 html 文档。
第三代是引入数据模型(json/xml),绑定自定义实现指令从而渲染 html 文档。
第四代主要是直接抛弃整个 contenteditbale,单独制定选区和监听输入事件。
更多关于编辑器的介绍,可参考有道技术团队之前发布的文章:
为什么要介绍富文本编辑器内容呢,因为了解多这些编辑器实现方式和保存机制可以帮助后面实现并优化扩展的功能。
4.3 初想
一开始的想法是,将原始编辑器的纯文本内容提取并发送到服务端,然后根据服务端返回的数据进行重新的拼接,在错误节点位置使用特殊标记标签进行标注。

以有道写作 Web 端为例:

使用这种方法实现批改效果的还有 163 邮箱英文智能检查、Gmail 自带写信语法检测功能等。这种方法适合我们**自定义的编辑器,**可以自己控制文本的渲染和指令。
但由于浏览器扩展是基于别人写的编辑器上进行的辅助工具,不能随意修改其文本格式和样式。比如复制带有划线的文本进行粘贴,会出现冗余的划线(除非原本的编辑器有做粘贴文本的标签过滤),但是不能寄希望于别人写的编辑器都有这个功能。
4.4 实现
需要分别从两个部分进行考虑:
如何定位画线
如何接受建议替换正确文本
如何定位画线,并且可以给予其高亮的效果呢?
需要解决的问题是:需要在不影响原编辑器的格式以及功能前提下,将结果划线部分加入到界面上。
>>> contenteditabe:
第一步:虚拟辅助器边框
虚拟一个元素(大小相同,位置相对)在原始编辑器之上,将结果划线标注在这个元素之上,我称之为辅助器。
因为是覆盖在原始编辑器上,需要禁止辅助器的鼠标响应行为,在 hover 的时候需要将鼠标位置同步到辅助器之上。


辅助器需要和原编辑器相同,才能定位准确,需要获得原编辑器以下属性。

第二步:找准定位
**问题:**如果单纯提取元素的 innerHTML/InnerText/textContent 作为批改请求的参数,无法实现准确定位。
**原因:**服务端返回结果是根据纯文本定位,网页上的编辑器格式是 HTML 文档格式,包含不同字体不同格式的标签。
举个例子:html 中有两个块级元素,分别展示两句话,差异只在于两句话 font-size 不一样。

通过 element.textContent 提取出来的内容都是相同的,校验返回的错误标志结果也相同,如下:

因为无法从纯文本的角度得到两种情况差异,难点就在于:需要解析 html 格式,将服务端数据转换到实际格式位置中。
要知道,这相当于要在一个空白的白板元素里添加一个多个绝对定位的高亮元素。需要知道每个错误相对原编辑框的相对位移,和自身宽高。

下面是一个反向推敲的过程:
我需要得到的是 hightlightElement : { top, letf, width, height };
通过 range.getClientRects() 可以获得我们想要的数据。
于是需要知道如何获取一个错误节点对应的 range。

需要找到对应的开头节点和它的相对位移、以及结束节点和它的相对位移。range: { startNode, stratOffest, endNode, endOffest}。方法就是通过错误节点在纯文本的开始(fixedposStart)跟结束位置(fixedposEnd)通过遍历全文每一个文本节点(textNodes)的数据长度(textNodes.nodeValue)进行计算得出。
fixedposStart、fixedposEnd 根据服务端返回数据经过稍微计算可得出。全文每一个文本节点(textNodes)需要通过过滤某些标签得到。
所以需要先思考如何处理 html 中各种标签问题。
**所以划线的原理是:**提取其纯文本的 textnode 节点,根据结果 position 匹配开始的节点和位移、结束节点和位移,获取其文本片段 range 对应编辑器的 x,y,height,width,画出高亮区域。
具体步骤如下:
a. 根据原文所有 html 标签加工过滤,提取纯文本和加工后的文本节点集合:

html 内有各种标签节点,需要根据这些标签不同意义,对标签内的文本进行加工。比如针对 p 标签,通常是表示段落,需要将其包裹的内容后面添加一个换行符。


p 标签处理例子:问题: 这个换行符是一起发给服务端的,服务端返回来的数据定位也算上了这个换行符。解决方案: 过滤标签的同时记录文本处理过的位置,在后面的计算反向处理。同时还需要注意字符的转义问题,尤其注意零宽字符的处理。

b. 提取纯文本节点:

(上图文本内容根据标签内容分成 5 个纯文本节点)

c. 结合服务端数据计算每个错误全文定位:

比如 has 错误对应的错误节点信息。

d. 根据定位获取每个错误节点文本片段:

e. 通过文本片段获取相对视口的位置:


划线步骤图
第三步:在 assist 范围内画出线和高亮

contenteditable 集合辅助器工作的流程图
>>> textarea:
textarea 本身是无法获取其 textnode 的,它相当于只有一个节点。考虑将其转换成文本节点:
创建一个隐形 mirror,这个 mirror 具备与原始编辑器相同边框大小、可编辑区域。

textarea 任何文本变动同步到 mirror
再为这个 mirror 创建一个 assist,同理上面处理 contenteditbale 的流程相一致。

>>> 关于突变:
编辑器其实就是一个普通的元素,以下编辑器的交互会引起我们页面内文本节点的变化:
文本内容变化
尺寸变化(窗口变大变小)
位置变化
字体大小变化(加粗,居中)
滚动
这些变化也就影响我们定位的变化,称之为突变。需要处理每一个突变引起的重新定位问题(重点难点)。
同时,需要监听原始编辑器的输入、字体变化、编辑器尺寸变化等等触发 assist 的重新定位方法。
ResizeObserver 兼容性问题需要通过 polyfill 库文件解决。


重新定位方法(mutation):
通过新旧 textnode array 比对,正向遍历节点集合和反向遍历节点集合,得到被修改的 textnode 是哪一个段文本节点 textnode 集合。
只需要处理被影响的 textnode 所对应的错误节点集合根据移动的 offest 计算后面影响的节点位移。

其他错误相对自己 textnode 的位移是不会改变的。
如何接受一个建议,替换文本:
替换文本意味要修改原编辑器的数据甚至格式,就会造成刚才说的对部分编辑器会引起格式错乱和保存失败的情况。
**难点:**不影响原始数据存储格式,不影响原始编辑器撤回操作,同时还能触发原编辑器保存机制。
**解决方法:**不直接用脚本修改 dom 节点,模拟用户修改数据的方式:选中文字,替换内容。

以新版有道云笔记为例子:
通过之前复杂计算得到结果片段,根据结果片段计算出对于可视窗口的位置,得到 {top, left, height, width}。
模拟鼠标从左向右滑动的操作事件加在内容区域。

找到自定义的自绘区域。
一个错误结果中可能涉及不同的样式,我们仅获取当前节点第一个片段的字体样式,模拟一个粘贴事件。

在自绘区域触发自定义粘贴事件。

4.5 增强编辑框
入口在点击右下角按钮或者 hover 出现结果卡片时候,点击详细建议进入
>>> 增强编辑框的作用:
提供更大的编辑空间
查看详细的批改结果


增强编辑框是一个特殊的 contenteditable 编辑器。
>>> 初始化、关闭赋值:
在初始化增强编辑器的时候,直接获取原编辑器数据,这里忽略了原编辑器的一些样式、图片,只使用 html 数据部分。

在增强编辑器中编辑后返回原编辑器时候,需要将新数据返回赋值。

>>> 通信:
增强编辑框是嵌入页面的 iframe,只在顶层页面出现。与原来页面的通信是通过 postMessage 方式。
(注意:postMessage 不能传递 html 元素和过于复杂的 json object)
如果是原本编辑器是 iframe,需要找到最上层 window.top,利用 window.top 和增强编辑框进行通信。


五、整体流程

上图为有道写作浏览器扩展从注入到浏览器页面,以及运行的大致流程。
为了在不影响用户操作前提下,扩展脚本只会在当前页面空闲时候加载,并且批改功能只在部分被用户点击 focus 的编辑器中激活。
以上是开发有道写作浏览器扩展过程中的开发思路和部分技术实现细节,借此机会分享给大家,欢迎与有道技术团队一起探讨更多关于前端、浏览器扩展的知识问题。
版权声明: 本文为 InfoQ 作者【有道技术团队】的原创文章。
原文链接:【http://xie.infoq.cn/article/0b2c62fb13998f07c468d7ca8】。文章转载请联系作者。
评论