从零开始的富文本编辑器(下)
胡炜轩
资深开发工程师往期回顾: 上次我们介绍了 《从零开始的富文本编辑器(上)》 ,其中我们探讨了contenteditable属性以及slatejs框架的api,本篇我们将接续上篇内容着重介绍一些实践细节。
首先我们进入slatejs的网站
https://docs.slatejs.org/(点击文末左下角“ 阅读原文 ”直达网站)
点击examples,可以看到一个官方示例:
点击View Source进入github
好了本章结束😊
开个玩笑。
先简单分析下demo中的代码:
工具栏区
可以看到加粗、斜体、下划线等标记被定义为Mark,对应前文中提到的Text元素,其余如大小标题、引用、列表、对齐等,则被定义为Block,对应前文中Element的Block属性。
Mark的处理
点击工具栏的加粗、斜体、下划线等按钮,会调用 toggleMark 方法,为元素添加标记:
点击之后会编辑器中会生成对应的结构:
其对应的渲染如下:
Block的处理
点击工具栏的大小标题、引用、列表、对齐等按钮,会调用 toggleBlock 方法:
红框内为核心逻辑,即先将结点变成普通的文本,然后将其改变到目标类型。demo中的代码对list(即DOM中的ul、ol)做了特殊处理,是因为作者想让list元素可以包裹住其他元素,而不是直接改变。
比如用户框选了一个标题元素,再点击列表按钮,则会把这个将这个标题放在list下的一个item中。此处大家可以根据实际需求修改。比如我们这里的需求是list可以有多层,我会在后文中单开一节来详细描述其实现方案。
点击之后会编辑器中会生成对应的结构:
其对应的渲染如下:
ok,现在你已经懂得了如何通过slatejs实现简单的富文本编辑器了。但是整个demo中,只有文字排版相关的内容,并没有图片。
我会在后文单开一节来介绍图片的实现。
需求描述
在我们的场景里面,需要实现如下效果:
列表支持多级,无序列表的前标为实心圆、空心圆、实现方块,超过3级则按这个顺序重复。
有序列表的前标则为阿拉伯数字、英文字母、罗马数字。
如果你对DOM了解,可以发现ul的嵌套,默认就是这个样子的,ol则需要定义一下css。
实现方案
点击按钮的实现与前文中所述相同。要创建一个多级列表,用户常见的操作是回车后按tab键,在word之类的软件中都是如此。那这里要怎么做呢?
首先是换行的处理,当然不是捕获键盘事件判断是否是回车再执行命令。这里我们覆写Editor中默认的insertBreak方法。
insertBreak方法默认会向编辑器选中区增加换行。
// withLists handles behavior regarding ol and ul lists// more specifically, withLists properly exits the list with `enter` or `backspace`// from an empty list item, transforming the node to a paragraphexport const withLists = (editor: Editor) => {const { insertBreak, deleteBackward } = editorconst doWithLists = (callback: Function) => {const { selection } = editor// check that there is a current selection without highlightif (selection && Range.isCollapsed(selection)) {// find the 'closest' `list-item` elementconst [match] = Editor.nodes(editor, {match: (n: any) =>n.type === 'list-item' &&n.children &&n.children[0] &&(!n.children[0].text || n.children[0].text === ''),})// check that there was a matchif (match) {const [, path] = matchconst start = Editor.start(editor, path)// if the selection is at the beginning of the list itemif (Point.equals(selection.anchor, start)) {// 'lift' the list-item to the next parentliftNodes(editor)// check for the new parentconst [listMatch] = Editor.nodes(editor, {match: (n: any) =>n.type === 'bulleted-list' || n.type === 'numbered-list',})// if it is no longer within a ul/ol, turn the element into a normal paragraphif (!listMatch) {Transforms.setNodes(editor,{ type: 'paragraph' },{ match: (n: any) => n.type === 'list-item' })}return}}}callback()}// override editor function for breakeditor.insertBreak = () => {doWithLists(insertBreak)}// override editor function for a backspaceeditor.deleteBackward = (unit) => {doWithLists(() => deleteBackward(unit))}return editor}
核心代码逻辑是先在选中区中寻找list元素,如果找到则可判断当前处于一个list当中。
此时回车执行liftNodes方法。liftNodes的核心逻辑是Trasnforms.liftNodes,它的功能为在文档树中向上提升指定位置的节点。如有必要,将拆分节点。
比如我在一个list-item的中部敲回车,那么回车前的文字会留在原处,而回车后的文字会从Text变为一个Element,由于它处于一个list中,会自动变成一个list-item。
接下来我们处理缩进,如果当前处于一个list当中,则将选中处再用list元素包装一层,形成类似DOM中ul...li...ul的结构,完成缩进。如果当前不处于list中,直接加空格或制表符完成缩进。
export const indentItem = (editor: Editor, maxDepth = MAX_DEPTH) => {const { selection } = editor// check that there is a current selection without highlightif (selection && Range.isCollapsed(selection)) {const [match] = Editor.nodes(editor, {match: (n: any) => n.type === 'list-item',})// check that there was a matchif (match) {// wrap the list item into another list to indent it within the DOMconst [listMatch] = Editor.nodes<any>(editor, {mode: 'lowest',match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list',})if (listMatch) {let depth = listMatch[1].lengthif (depth <= maxDepth) {Transforms.wrapNodes(editor, {type: listMatch[0].type,children: [],})}}} else {Transforms.insertText(editor, ' ', {at: { path: [selection.anchor.path[0], 0], offset: 0 },})}}}
取消缩进是其反操作,如果当前是list,则将其置为paragraph(段落,最普通的纯文本Element)。如果当前不是list,判断前面是否有可以删除的空格。
export const undentItem = (editor: Editor) => {const { selection } = editor// check that there is a current selection without highlightif (selection && Range.isCollapsed(selection)) {const [match] = Editor.nodes(editor, {match: (n: any) => n.type === 'list-item',})// check that there was a matchif (match) {// 'lift' the list-item to the next parentliftNodes(editor)// check for the new parentconst [listMatch] = Editor.nodes(editor, {match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list',})// if it is no longer within a ul/ol, turn the element into a normal paragraphif (!listMatch) {Transforms.setNodes(editor,{ type: 'paragraph' },{ match: (n: any) => n.type === 'list-item' })}} else {const [pMatch] = Editor.nodes(editor, {match: (n: any) => n.type === 'paragraph'|| n.type === 'block-quote'|| n.type === 'heading-one'|| n.type === 'heading-two'|| n.type === 'heading',})if ((pMatch[0] as any).children[0].text.startsWith(' ')) {Transforms.delete(editor, {at: { path: [selection.anchor.path[0], 0], offset: 0 },distance: 4})}}}}
以上就是多级列表的实现。目前整体还比较简单,后文我们会开始讲解目前整个编辑器中最复杂的图片。
终于来到了最复杂的图片元素。
我们还是先打开官方demo,在左侧的菜单中选择images:
然后你会看到这样的demo:
Exciting!是不是又结束了?然鹅并没有。
我们点击图片按钮,会发现要求我们输入一个图片的链接。这显然过于简陋。我们需要让用户直接选择本地的图片上传。
方案设计
经过与服务端的讨论我们确定了三个方案:
方案三 前端提供一个浮层批量上传图片,轮询服务端查询上传状态,同时服务端做异步化改造。所有图片上传成功后,一次性插入编辑器。此方案实现较复杂,但用户体验较好。
综合来看,我们选择 方案三 进行开发。
实现方案
将图片定义为空元素:
const withImages = editor => {const { isVoid } = editoreditor.isVoid = element => {return element.type === 'image' ? true : isVoid(element)}return editor}
用户点击工具栏图片按钮时,弹出浮层让用户进行上传。上传完毕后,对所有图片链接,调用insertImage方法:
export const insertImage = (editor: Editor, url: string) => {const { selection } = editorconst text = { text: '' }const image: ImageElement = { type: 'image', url: trimHttp(url), desc: '', children: [text] }if (selection) {const [match] = Editor.nodes(editor, {match: (n: any) =>n.type === 'paragraph' &&n.children &&n.children[0] &&(!n.children[0].text || n.children[0].text === ''),})if (match) {Transforms.setNodes(editor, image, {mode: 'highest'})} else {Transforms.insertNodes(editor, image, {mode: 'highest'})}} else {Transforms.insertNodes(editor, image, {mode: 'highest'})}}
如果当前光标在一个空行上,则将当前元素之间转换为图片元素。
否则,在光标位置插入一个图片元素。若编辑器中不存在焦点,插入编辑器末尾。
对应渲染方法:
const Image = ({ attributes, children, element }) => {const editor = useSlateStatic()const path = ReactEditor.findPath(editor, element)const selected = useSelected()const focused = useFocused()return (<div {...attributes}>{children}<divcontentEditable={false}className={css`position: relative;`}><imgsrc={element.url}className={css`display: block;max-width: 100%;max-height: 20em;box-shadow: ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'};`}/></div></div>)}
注意这里将图片元素的内部设置为了contentEditable=false。如果不设置这个属性,图片的前后会出现光标,可以输入文字。如果想要优化这个效果,需要监听onkeydown等事件,在图片前后输入内容进行自动换行等。
图片结构上也可以自由扩展,比如增加一个输入框输入图片注释等,这里就不展开讲了。
至此,我们完成了【从零开始的富文本编辑器】中的所有内容。当然,实际开发的过程中,为了满足各种编辑需求,还需要添加快捷键等功能,同时多种排版之间状态的切换也需要仔细打磨,更不要提莫名其妙的兼容性bug了。
这些问题都不是一个所谓“完美“的架构能够解决的,更多需要你去思考怎样的体验是更优秀的,去花时间研究不同浏览器的区别并解决问题。这大概就是一个前端工程师的宿命了。
以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,请给我们点个赞吧!
本期参考链接:
[1]https://www.slatejs.org/examples/richtext[2]https://github.com/ianstormtaylor/slate/blob/main/site/examples/images.tsx