深入浅出富文本编辑器

发表于 2年以前  | 总阅读数:363 次

编辑器介绍

常见的富文本编辑器现实方式可以分成两大类,分别是用 textarea 和 contenteditable 来实现。

textarea

结构简单使用方便,一些文本格式和复杂的样式难以实现,推荐仅在对编辑要求不高的场景使用。

contenteditable

将元素的 contenteditable 属性设为 true时,该元素则成为了编辑器的主体。配合 document.execCommand 能够实现绝大多数功能,主流编辑器是基于 contenteditable 来设计的。

但是单纯依赖 contenteditable 直接产出 html 会带来一些问题,例如相同的输入在不同浏览器下的输出可能不一致,相同的输出在不同浏览器中展示存在差异,并且这些问题在移动端会被放大,同时 html 使用具有局限性,不方便在跨平台间使用。

因此更好的方案是制定一套数据结构 + 文档模型,所有的输入都经过编辑器生成约定的产物,这样在不同的平台均可解析并且保证得到预期的效果。

还有一类是以 Google docs 为主的编辑器,不使用 contenteditable ,而是基于 canvas 渲染[1],通过监听用户输入,模拟编辑器的运行,此类编辑器实现成本极高且复杂。

本文以 quill[2] 为例,介绍如何实现一个支持跨平台渲染,且可以插入自定义模块的富文本编辑器。

基本概念

delta[3]

用于描述富文本内容或内容变换的数据结构,纯 json 格式,能够转化成 js 对象后方便操作,基本格式如下,由一组 op 组成。

op 是个 js 对象,可理解为对当前内容的一次变更,它主要有以下几个属性。

insert: 插入,后面 【3.2 数据结构】有介绍可能的值和对应的含义

retain: 值为 number 类型,保留相应长度的内容

delete: 值为 number 类型,删除相应长度的内容

上面三个属性必有且仅有一个出现在 op 对象中

attributes: 可选,值为对象,可描述格式化信息

如何理解内容或内容变换,举个,下面这段数据表示了内容 “Grass the Green”,

{
  ops: [
    { insert: 'Grass', attributes: { bold: true } },
    { insert: ' the ' },
    { insert: 'Green', attributes: { color: '#00ff00' } }
  ]
}

经过下面一次 delta 内容变换后新内容为 “Grass the blue”。

{
  ops: [
    // 接下来 5 个字符取消加粗并加上斜体格式
    { retain: 5, attributes: { bold: null, italic: true } },
    // 维持 5 个字符不变
    { retain: 5 },
    // 插入
    { insert: "Blue", attributes: { color: '#0000ff' },
    // 删除后面 5 个字符
    { delete: 5 }
  ]
}

Delta 本质上是一系列操作记录,在渲染时可以看作记录了从空白到目标文档的一个过程,而 HTML 是一个树形结构,所以 Delta 的线性结构相比 HTML 在业务使用上有天生优势

parchment[4]

一种文档模型,由 blots 组成,用来描述数据,可以拓展自定义的数据。

<p>
    一段文字加视频的富文本内容。
    <img src="xxx" alt="">
  </p>
  <p>
    <strong>加粗文本结尾。</strong>
</p>

parchment 与 blot 关系类似于 DOM 与 element node,上面一段 html 内容使用 dom tree 和 parchment tree 描述分别如下图所示。

parchment 提供了几种基础 blot,同时支持开发中根据需求拓展定义自己的 blot,后面会演示如何开发一个自定义的 blot。

{
  // 基础节点
  ShadowBlot,
  // 容器节点 => 基础节点
  ContainerBlot,
  // 格式化节点 => 容器节点
  FormatBlot,
  // 叶子节点
  LeafBlot,
  // 编辑器根节点 => 容器节点
  ScrollBlot,
  // 块级节点 => 格式化节点 
  BlockBlot,
  // 内联节点 => 格式化节点 
  InlineBlot,
  // 文本节点 => 叶子节点
  TextBlot,
  // 嵌入式节点 => 叶子节点
  EmbedBlot,
}

最后用一张图了解下 quill 内部的工作流程,其中开发者需要关注的业务层逻辑十分简洁,可以通过手动输入和 api 方式变更编辑器内容,同时 editor-change 事件会输出当次操作和最新内容对应的 delta 数据。

实际应用

数据流

在业务中,基本数据流应该如下图所示,由编辑器生成 delta 数据,之后由相应平台的解析器渲染成对应的内容。

数据结构

良好的内容数据结构设计,在后续维护和跨平台渲染时起到关键作用,我们可以将富文本内容中依赖的媒体(图片、视频、自定义的格式)数据放到外层来,通过 id 关联,这样日后拓展和渲染时会比较方便。

interface ItemContent {
    // 富文本数据,存储着 delta-string
    text?: string;
    // 视频
    videoList?: Video[];
    // 图片
    imageList?: Image[];
     // 自定义的模块,如投票、广告卡片、问卷卡片等等
    customList?: Custom[];
}

其中编辑器输出的是标准 delta 数据, 结构如下所示,

// 纯文本, \n 代表换行
{
    insert: string;
},
 // 特殊类型的文本
{
    insert: '超链接文本',
    attributes: {
        // 文字颜色
        color: string,
        // 加粗
        bold:  boolean,
        // 超链接地址
        link: string;
        ...,
    }
},
// 有序无序列表
{
    insert:  '\n',
    attributes: {
      list: 'ordered' | 'bullet'
    }
 },
{
    insert: {
        uploading: {
            // 资源类型
            type: 'image' | 'video' | 'vote' | 'and more...'
            // 资源 id
            uid: string
        },
    },
},
// 图片
{
    insert: { image: '${image_uri}' }
},
// 视频
{
    insert: {
        videoPoster: {
           /** 视频封面地址 */            url: string;
           /** 视频 id */            videoId: string;
        }
    }
},
// 投票
{
    insert: {
        vote: {
            voteId: string
        }
    }
},
// 缩进,作用域内所有文本向右缩进 indent 个单位;
// 作用域:从当前为起始位置向前回溯,遇到以下任意一种情况结束
// 1、纯文本 \n
// 2、attributes的属性含有indent并且indent值小于等于当前值
{
    insert:  '\n',
    attributes: {
        indent: 1-8,
    }
},

图片 / 视频混排

图片上传需要支持展示上传中的状态,并且不应该阻塞用户的编辑,所以需要先使用一个占位元素,待上传完成后将占位替换成真实图片或视频。

自定义 blot

自定义 blot 的好处是能够将整个的功能(例如图表功能)封装到一个 blot 中,这样业务开发时可直接使用,而不用管每个功能是怎么实现的。下面以图片视频上传态占位 blot 为例,演示如何自定义一个 blot。

import Quill from 'quill';

enum MediaType {
  Image = 'image',
  Video = 'video',
}

interface UploadingType {
  type: MediaType;
  // 唯一的 id,当图片或视频上传完成后,需要找到对应的 uid 进行替换
  uid: string;
}

export const BlockEmbed = Quill.import('blots/block/embed');

class Uploading extends BlockEmbed {
  static _value: Record<string, UploadingType> = {};

  static create(value: UploadingType) {
    const ELEMENT_SIZE = 60;
    // blot 对应的 dom 节点
    const node = super.create();
    this._value[value.uid] = value;
    node.contentEditable = false;
    node.style.width = `${ELEMENT_SIZE}px`;
    node.style.height = `${ELEMENT_SIZE}px`;
    node.style.backgroundImage = `url(占位图地址)`;
    node.style.backgroundSize = 'cover';
    node.style.margin = '0 auto';
    // 用来区分对应资源
    node.setAttribute('data-uid', value.uid);
    return node;
  }

  static value(v) {
    return this._value[v.dataset?.uid];
  }
}

Uploading.blotName = 'uploading';
Uploading.tagName = 'div';

export default Uploading;

将自定义 blot 注册到编辑器实例中,使用 quill 的 insertEmbed 来调用这个blot 即可。

// editor.tsx
Quill.register(VideoPosterBlot);

quill.insertEmbed(1, 'uploading', {
  type: 'image',
  uid: 'xxx',
});

处理粘贴操作

复制粘贴可以大幅提升编辑器效率,但是我们需要对剪切板中的视频和图片进行特殊处理,将剪切板中的内容转化成自定义的格式,并自动上传其中图片和视频。

基本原理

监听用户的粘贴操作,读取 paste event[5] 返回的 clipboardData[6] 数据,二次加工后再插入编辑器中。


target.addEventListener('paste', (event) =>  {
    const clipboardData = (event.clipboardData || window.clipboardData)
    const text = clipboardData.getData(
      'text',
    );
    const html = clipboardData.getData(
      'text/html',
    );

    /**
    * 业务逻辑
    */

    event.preventDefault();
});

clipboardData.itemsDataTransferItem 的数组集合,它包含了本次粘贴操作的数据内容。

DataTransferItem 有两个属性分别是 kindtype,其中 kind 值通常是 string 类型,如果是文件类型的数据那么值为 filetype 值是 MIME 类型,常见的是 text/plaintext/html

处理图片

剪切板中的图片来源分为两大类,一是直接从文件系统中复制,这种情况我们

从文件系统中复制

从文件系统中复制粘贴后,能获取到 File 对象,那么直接插入编辑器中,即可复用前面的图片上传逻辑。

从网页复制

从上面右图不难看出,从网页中复制过来的内容中包含 text/html 富文本类型,由于图片可能是临时地址,直接使用三方图片地址不可靠,需要把 html 中图片地址提取出来,下载后再上传至我们自己的服务器中,图片上传模块还能继续复用上文的图片混排。

上文内容的 dom 树基础结构如图所示,可以经过后序遍历将所有节点处理成数组结构,当遇到节点为图片时则调用上面的图片混排逻辑。

convert({ html, text }, formats = {}) {
    if (!html) {
      return new Delta().insert(text || '');
    }
    // 返回 HTMLDocument 对象
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const container = doc.body;
    // key - node
    // value - matcher: (node, delta, scroll) => newDelta
    const nodeMatches = new WeakMap();
    // 返回两个匹配器,分别处理 ELEMENT_NODE 和 TEXT_NODE ,将 dom 转化成 Delta
    const [elementMatchers, textMatchers] = this.prepareMatching(
      container,
      nodeMatches,
    );

    return traverse(
      this.quill.scroll,
      container,
      elementMatchers,
      textMatchers,
      nodeMatches,
    );
}


 function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
  // 节点为叶子节点即文本
  if (node.nodeType === node.TEXT_NODE) {
    return textMatchers.reduce((delta, matcher) =>  {
      return matcher(node, delta, scroll);
    }, new Delta());
  }
  if (node.nodeType === node.ELEMENT_NODE) {
    return Array.from(node.childNodes || []).reduce((delta, childNode) =>  {
      let childrenDelta = traverse(
        scroll,
        childNode,
        elementMatchers,
        textMatchers,
        nodeMatches,
      );
      if (childNode.nodeType === node.ELEMENT_NODE) {
        childrenDelta = elementMatchers.reduce((reducedDelta, matcher) =>  {
          return matcher(childNode, reducedDelta, scroll);
        }, childrenDelta);
        childrenDelta = (nodeMatches.get(childNode) || []).reduce(
          (reducedDelta, matcher) =>  {
            return matcher(childNode, reducedDelta, scroll);
          },
          childrenDelta,
        );
      }
      return delta.concat(childrenDelta);
    }, new Delta());
  }
  return new Delta();
}

上面例子中的数据可以转化成以下 delta 数据,视频的处理方法与图片类似,这里不再赘述。


{
    ops: [
    {
        insert: '说起艾冬梅这个名字,现在的年轻人可能不是很熟悉,但是她曾经却是家喻户晓的人物,'
    },
    {
        insert: '艾冬梅是我国著名的马拉松运动员'  ,         attribute: {
            bold: true
        },
    },
    {
        insert: '。她出生于1981年,是个来自东北的姑娘,和很多普通的八零后一样,她来自一个平凡的家庭,从小生活十分幸福,家境虽然不富裕,但艾冬梅依然是父母的掌上明珠。'
    },
    {
       insert: {
           image: {
               url: 'xxx'
           }
       }
    },
    {
        insert: '但是艾冬梅和其他人不同的是她从小就展现出了惊人的长跑天赋'  ,         attribute: {
            bold: true
        },
    },
    {
        insert: ' , 1993年当时艾冬梅还在念小学,她在一次跑步比赛中获得了一个十分优秀的成绩,在脚趾头受伤的情况下打破了当地的3000米项目记录,远远超过了参赛的所有人。这让很多人都十分震惊,于是艾冬梅顺利地被齐齐哈尔体校选中。'
    }
   ]
}

解析数据

在 web 场景下可以使用 quill-delta-to-html[7] 这个库来做解析,如果是小程序,对于媒体元素(如:小程序中图片必须要指定宽高[8])支持相对不太友好,需要自己解析,下面简单介绍下如何渲染 delta 数据。

由于 delta 是一个线性结构,转化成 dom 时,需要构建一棵树,将块级元素的子元素关联到它的 children 中。

上图中的原数据经过第一轮处理

  1. 纯文本反规范化,将 abc\ndef\ng 格式转化成 [abc, \n, def, \n, g]
  2. 将块级元素的元信息,写入第一个 op 中

块级元素的元信息包括:缩进,有序列表序号,【当前元素所在块级元素】在原数据中的起始与终止索引,【当前元素所在块级元素】在 dom 列表中的索引

经过上面转化后原数据变成上图中的格式,每个 op 都含有相应的元数据,接下要做的就是解析这些 op,将其转化成 Element。

对于自定义 blot 的渲染,我们可以封装成组件(react 或 vue 组件,取决你使用什么框架),这样业务功能和编辑器开发可解耦,不了解编辑器代码的同学也能够参与开发。

小结

至此,我们已经了解开发编辑器的基本流程和需要重点关注的一些事项。如果业务中需要拓展一些功能卡片,如飞书文档的各种应用,可通过拓展 blot + 编写对应的组件来实现。此外还能够通过编写相应平台的解析器在非 web 场景的展示,轻松实现内容跨平台渲染。

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/Cs4jc5nHQDCAUtGRa3fuew

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237227次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8063次阅读
 目录