背景
富文本编辑器不仅仅是图文,需要扩展更多类型的信息。像比较出名的 notion 编辑器直接就是一个“大杂烩”,啥都能塞进去。
wangEditor 正在考虑做全面的插件化,也是近期我就以“公式”和“代码块”为例子,探索了一下 embed 的设计和实践。正好,这俩是不同的显示类型,前者是 inline 后者是 block 。
PS:embed 卡片机制,并不是什么新鲜东西,一些优秀的开源编辑器(slate.js Quill 等)早就支持。当前的一些知识库产品(腾讯文档、石墨等)也都有很成熟的应用。
扩展性
embed 卡片,需要被设计为自由可扩展的,才能发挥他的最大价值。它可以随着扩展菜单和插件,同时被注册到编辑器,从而扩展格式。
例如,默认情况下编辑器只支持基础的文本编辑(这样轻量化,代码体积小),不支持数学公式。
你可以开发一个第三方的数学公式菜单,注册到编辑器中,这样就有了公式菜单。同时,还要注册一个公式的 embed 卡片,这样编辑区域就可以显示公式卡片。
同理,你可以继续扩展其他的菜单、embed、插件……
菜单可扩展的,插件可扩展,embed 当然也需要可扩展。
应用场景
embed 应用场景非常多,除了普通文字编辑之外,剩下的都可以用 embed 来实现。而且有些交互性复杂的(如表格、代码块)必须用 embed 才能做出好的用户体验。
下面列出一些常见的使用场景:
- 链接、链接卡片
- 图片
- 公式
- 代码块
- 视频
- 表格
- 附件
- 各种可嵌入的文档和服务,如思维导图、ppt、地图等
- 可根据业务,高度定制自定义的组件,如常见的 logo 、时间轴、图文混排的小卡片等
有了 embed ,可以让编辑器区域为所欲为!
设计和实现
设计调整了很多次,一边调整一边改代码。最终有了目前的阶段性成果,虽然还不完善,但我感觉已经可以照着这个思路来继续进行了。
注册 embed 卡片
还是以数学公式为例子,embed 随着菜单一起被注册。菜单被注册到菜单栏,embed 被注册到编辑器,等待使用。
其中,KaTex 是渲染 LaTeX 语法公式的 lib ,可以直接 npm 安装使用。
【注意】这里关于 KaTex 有一个很关键的话题 —— 如何保证编辑器基础部分代码体积小?
有一个很重要的因素就是:把这些第三方的 lib ,放在第三方扩展的扩展代码(插件、菜单、embed)里,谁用谁安装。这也是做插件化拆分的
核心代码如下。首次,编辑器需要定义必要的接口
export interface IEmbed {
id: string
embedKey: string
isBlock: boolean
data: any
readonly $container: DomElement // getter
render($container: DomElement): void
genResultHtml(): string
}
export interface IEmbedConf {
key: string
isEmbedElem($elem: DomElement): boolean
getDataFromElem($elem: DomElement): any
createEmbedInstance(data: any): IEmbed
}
公式 embed 的核心 class
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { IEmbed } from '../../../embed/IEmbed'
import { getRandom } from '../../../utils/util'
import $, { DomElement } from '../../../utils/dom-core'
import { EMBED_KEY } from './const'
class FormulaEmbed implements IEmbed {
id: string
public embedKey: string = EMBED_KEY
public isBlock: boolean = false // display: inline-block
public data: string = ''
constructor(data: string) {
this.id = getRandom(`${EMBED_KEY}-`) // id 会对应到 embed 容器的 DOM 节点
this.data = data
}
public get $container(): DomElement {
return $(`#${this.id}`)
}
/**
* 渲染公式
* @param $container embed 容器
*/
public render($container: DomElement): void {
const data = this.data as string
katex.render(data, $container.getNode(0) as HTMLElement, {
throwOnError: false,
})
}
/**
* 获取 result html ,执行 txt.html() 时触发
* @returns html 代码
*/
public genResultHtml(): string {
const embedKey = this.embedKey
const data = this.data
// 要和 selector getData() 对应好
return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`
}
}
export default FormulaEmbed
插入公式
插入公式时,需要输入 LaTeX 语法的字符,然后最终于 KaTeX 渲染成为数学公式。
对插入 embed 的操作,编辑器做了统一的命令模式处理。即执行 insert(embedKey, data)
即可插入 embed 。插入的过程是:
- 创建对应的 embed 实例
- 创建一个
$container
,append 到编辑区域 - 执行
embedInstance.render($container)
把 embed 卡片渲染到$container
里面
关键代码
/**
* 插入 embed 卡片
* @param key embed key
* @param data embed data
* @returns void
*/
public insertEmbed(key: string, data: any): void {
const editor = this.editor
const embed = editor.embed.createEmbedInstance(key, data)
if (embed == null) return
const $container = genEmbedContainerElem(embed)
this.insertElem($container)
embed.render($container)
}
这里的 $container
有几个非常重要的细节
- 必须是
contenteditable="false"
。embed 是一个默认不可编辑的黑盒,这一点非常重要! 这样才能保证 embed 的自由度,从而保证扩展性。 id
必须要和 embed 实例对应起来,这样通过 embed 实例可以一下子找到它的$container
display
要分为inline-block
和block
两种
生成 $container
的核心代码如下
/**
* 生成 embed 容器 elem
* @param embedInstance embed 实例
* @returns elem
*/
export function genEmbedContainerElem(embedInstance: IEmbed): DomElement {
const id = embedInstance.id
const isBlock = embedInstance.isBlock
// block
let tag = 'div'
let className = 'we-embed-card-block'
// inline
if (isBlock === false) {
tag = 'span'
className = 'we-embed-card-inline'
}
// 生成 $container 。注意 id 必须这样写,否则找不到 embedInstance.$container
const containerHtml = `<${tag} id="${id}" data-we-embed-card class="${className}" contenteditable="false"></${tag}>`
const $container = $(containerHtml)
// TODO 这里可以扩展很多事件和操作,例如删除、复制、全屏、拖拽等
return $container
}
获取结果
编辑区域内的 html ,和最终用户获取的 html 是不一样的,而且完全不一样。理解这一点非常重要!
例如,编辑区域使用 KaTeX 渲染数学公式,DOM 结构是非常复杂的,还要依赖于大量的 css 。而用户得到的 html 结果非常简单,就是 <span data-embed-key="${embedKey}" data-embed-value="${data}"></span>
。
再例如,代码块我们是借助 CodeMirror 来实现的代码编辑,CodeMirror 渲染出来的 DOM 结构也是非常复杂的。而用户得到的 html 结果就是普通的 <pre><code>xxxx</code></pre>
。
要做到这一步,就需要每个 embed 都分别做各自的解析。我们需要把这个解析逻辑写到 embed 实例的 genResultHtml
中。例如公式的 embed 这样写:
/**
* 获取 result html ,执行 txt.html() 时触发
* @returns html 代码
*/
public genResultHtml(): string {
const embedKey = this.embedKey
const data = this.data
// 要和 selector getData() 对应好
return `<span data-embed-key="${embedKey}" data-embed-value="${data}"></span>`
}
最终,通过一个统一的 renderHtml2ResultHtml
方法,来汇总所有 embed 实例的 genResultHtml
,把整个 html 解析完。这里借助了 htmlParser 来解析 html 字符串:
/**
* renderHtml --> resultHtml
* @param renderHtml renderHtml
* @param editor editor
*/
export function renderHtml2ResultHtml(renderHtml: string, editor: Editor): string {
let resultHtmlArr: string[] = []
let inEmbedFlag = 0 // 是否开始进入 embed 内部
const htmlParser = new HtmlParser()
htmlParser.parse(renderHtml, {
startElement(tag: string, attrs: IAttr[]) {
const idEmbed = hasEmbedMarkAttr(attrs)
if (idEmbed) {
// 开始进入 embed
inEmbedFlag = inEmbedFlag + 1
// 获取 embed 实例,获取 resultHtml ,并拼接
const embedId = getAttrValue(attrs, 'id')
const embedInstance = editor.embed.getEmbedInstance(embedId)
if (embedInstance == null) return
const resultHtml = embedInstance.genResultHtml() // 解析出 resultHtml
resultHtmlArr.push(resultHtml)
return
}
// 正常情况下,不是 embed ,则拼接 html
if (inEmbedFlag === 0) {
const html = genStartHtml(tag, attrs)
resultHtmlArr.push(html)
return
}
// embed 内部,继续深入一层。不拼接 html
if (inEmbedFlag > 0 && EMPTY_TAGS.has(tag) === false) {
inEmbedFlag = inEmbedFlag + 1
}
},
characters(str: string) {
// 正常情况下,不是 embed ,则拼接
if (inEmbedFlag === 0) {
resultHtmlArr.push(str)
}
},
endElement(tag: string) {
// 正常情况下,不是 embed ,则拼接 html
if (inEmbedFlag === 0) {
const html = genEndHtml(tag)
resultHtmlArr.push(html)
}
// embed 内部,减少一层。不拼接 html
if (inEmbedFlag > 0) inEmbedFlag = inEmbedFlag - 1
},
comment(str: string) {}, // 注释,不做处理
})
return resultHtmlArr.join('')
}
回显结果
刚刚获取 html ,是由 renderHtml 转换为 resultHtml 。而回显结果,就是由 resultHtml 转换为 renderHtml 。这是一个逆向工程。
但这一步不能再向上文一样,去解析 html ,因为这需要渲染编辑器区域的 DOM 。所以设计的步骤是:
- 先把 resultHtml 赋值给编辑区域
- 立马做一个转换,根据现有的 resultHtml 宣传出 renderHtml
function renderEmbed(editor: Editor): void {
// ------------ 先关闭 change 监听 ------------
// 遍历编辑区域
const $textElem = editor.$textElem
traversal($textElem, ($elem: DomElement) => {
// 判断是不是 embed,生成 embed 实例
const embedConf = editor.embed.getEmbedConfByElem($elem)
if (embedConf == null) return
const data = embedConf.getDataFromElem($elem)
const embedInstance = editor.embed.createEmbedInstance(embedConf.key, data)
if (embedInstance == null) return
// 生成 $container ,添加到当前元素后面
const $container = genEmbedContainerElem(embedInstance)
$container.insertAfter($elem)
// 调用 embed.render
embedInstance.render($container)
// 删除当前元素
$elem.remove()
})
// ------------ 最后再开启 change 监听 ------------
}
TODO
目前实现了一个大概的框架,还有很多细节需要做,例如
- embed 生命周期:创建、更新、销毁等
- embed 的事件:如 click、mouseEnter 等
- 编辑区域的其他 API 的改动,例如获取 text 、获取和设置 JSON 、append elem 、粘贴处理等
- embed 内部和外部的交互,例如 focus 到 codeMirror 时需要禁用编辑器的所有菜单。
总结
embed 我搞了好几天,还要继续再搞下去,感觉还是挺麻烦的。
等把所有的必要 embed 都扩展完,稳定之后,我还会再来写文章分享。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!