找回密码
 会员注册
查看: 15|回复: 0

从零开始的富文本编辑器(下)

[复制链接]

4

主题

0

回帖

13

积分

新手上路

积分
13
发表于 2024-10-6 19:14:20 | 显示全部楼层 |阅读模式
本期作者胡炜轩资深开发工程师往期回顾:上次我们介绍了《从零开始的富文本编辑器(上)》,其中我们探讨了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 paragraphexport 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(editor, { ? ? ? ?mode: 'lowest', ? ? ? ?match: (n: any) => n.type === 'bulleted-list' || n.type === 'numbered-list', ? ? ?}) ? ? ? ?if (listMatch) { ? ? ? ?let depth = listMatch[1].length ? ? ? ?if (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) { ? ? ?// '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 ( ? ? ? ? ?{children} ? ? ? ? ? ? ? ? ? ? ? ? ?)}注意这里将图片元素的内部设置为了contentEditable=false。如果不设置这个属性,图片的前后会出现光标,可以输入文字。如果想要优化这个效果,需要监听onkeydown等事件,在图片前后输入内容进行自动换行等。图片结构上也可以自由扩展,比如增加一个输入框输入图片注释等,这里就不展开讲了。至此,我们完成了【从零开始的富文本编辑器】中的所有内容。当然,实际开发的过程中,为了满足各种编辑需求,还需要添加快捷键等功能,同时多种排版之间状态的切换也需要仔细打磨,更不要提莫名其妙的兼容性bug了。这些问题都不是一个所谓“完美“的架构能够解决的,更多需要你去思考怎样的体验是更优秀的,去花时间研究不同浏览器的区别并解决问题。这大概就是一个前端工程师的宿命了。以上是今天的分享内容,如果你有什么想法或疑问,欢迎大家在留言区与我们互动,如果喜欢本期内容的话,请给我们点个赞吧!往期参考:《从零开始的富文本编辑器(上)》本期参考链接:[1]https://www.slatejs.org/examples/richtext[2]https://github.com/ianstormtaylor/slate/blob/main/site/examples/images.tsx
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-11 08:36 , Processed in 0.442716 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表