本期作者
胡炜轩
资深开发工程师
往期回顾:上次我们介绍了《从零开始的富文本编辑器(上)》,其中我们探讨了contenteditable属性以及slatejs框架的api,本篇我们将接续上篇内容着重介绍一些实践细节。
03 使用slatejs开发一个富文本编辑器
首先我们进入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中,只有文字排版相关的内容,并没有图片。
我会在后文单开一节来介绍图片的实现。
04 slatejs富文本编辑器之列表
需求描述
在我们的场景里面,需要实现如下效果:
列表支持多级,无序列表的前标为实心圆、空心圆、实现方块,超过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 paragraph
export const withLists = (editor: Editor) => {
const { insertBreak, deleteBackward } = editor
const doWithLists = (callback: Function) => {
const { selection } = editor
// check that there is a current selection without highlight
if (selection && Range.isCollapsed(selection)) {
// find the 'closest' `list-item` element
const [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 match
if (match) {
const [, path] = match
const start = Editor.start(editor, path)
// if the selection is at the beginning of the list item
if (Point.equals(selection.anchor, start)) {
// 'lift' the list-item to the next parent
liftNodes(editor)
// check for the new parent
const [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 paragraph
if (!listMatch) {
Transforms.setNodes(
editor,
{ type: 'paragraph' },
{ match: (n: any) => n.type === 'list-item' }
)
}
return
}
}
}
callback()
}
// override editor function for break
editor.insertBreak = () => {
doWithLists(insertBreak)
}
// override editor function for a backspace
editor.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 highlight
if (selection && Range.isCollapsed(selection)) {
const [match] = Editor.nodes(editor, {
match: (n: any) => n.type === 'list-item',
})
// check that there was a match
if (match) {
// wrap the list item into another list to indent it within the DOM
const [listMatch] = Editor.nodes<any>(editor, {
mode: 'lowest',
match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list',
})
if (listMatch) {
let depth = listMatch[1].length
if (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 highlight
if (selection && Range.isCollapsed(selection)) {
const [match] = Editor.nodes(editor, {
match: (n: any) => n.type === 'list-item',
})
// check that there was a match
if (match) {
// 'lift' the list-item to the next parent
liftNodes(editor)
// check for the new parent
const [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 paragraph
if (!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
})
}
}
}
}
以上就是多级列表的实现。目前整体还比较简单,后文我们会开始讲解目前整个编辑器中最复杂的图片。
05 slatejs富文本编辑器之图片
终于来到了最复杂的图片元素。
我们还是先打开官方demo,在左侧的菜单中选择images:
然后你会看到这样的demo:
Exciting!是不是又结束了?然鹅并没有。
我们点击图片按钮,会发现要求我们输入一个图片的链接。这显然过于简陋。我们需要让用户直接选择本地的图片上传。
方案设计
经过与服务端的讨论我们确定了三个方案:
方案一 前端选择图片文件后,将文件转换为base64url,插入编辑器中,服务端在提交时解析所有的image结点上传,并修改链接。
此方案的优点是用户编辑是体验最好,缺点是服务端处理数据压力较大,特别是图片多的时候。pass。
方案二 前端选择图片后,先向编辑器中插入一个loading元素占位,等服务端返回后再将其替换为真实地址。
优点是实现简单,服务端压力小,缺点是loading元素容易作为中间状态被保存下来,图片较大时loading等待时间长,整体交互用户体验交较差。
方案三 前端提供一个浮层批量上传图片,轮询服务端查询上传状态,同时服务端做异步化改造。所有图片上传成功后,一次性插入编辑器。此方案实现较复杂,但用户体验较好。
综合来看,我们选择方案三进行开发。
实现方案
将图片定义为空元素:
const withImages = editor => {
const { isVoid } = editor
editor.isVoid = element => {
return element.type === 'image' ? true : isVoid(element)
}
return editor
}
用户点击工具栏图片按钮时,弹出浮层让用户进行上传。上传完毕后,对所有图片链接,调用insertImage方法:
export const insertImage = (editor: Editor, url: string) => {
const { selection } = editor
const 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}
<div
contentEditable={false}
className={css`
position: relative;
`}
>
<img
src={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