一. 前言
二. 核心对象
2.1 SlateNode
富文本内容会构建成一个虚拟SlateNode Tree
,每个文本节点对应一个SlateNode
节点,当修改文本内容时会更新SlateNode Tree
节点数据,然后触发更新渲染,展示新的富文本内容
SlateNode Tree
示意图如下
2.2 SlateEditor
富文本编辑器对象,负责维护SlateNode Tree
,提供插入、删除文本等核心API
三. Browser API
3.1 beforeinput
事件
富文本容器节点会监听该事件,监听用户输入操作,更新SlateNode Tree
,触发重新渲染,展示新的富文本内容,更多内容参考文档
3.2 selectionchange
事件
监听用户光标选择内容,记录到SlateEditor
对象属性上,更多内容参考文档
3.3 document.getSelection
方法
获取Selection
对象,通过其setBaseAndExtent
方法设置光标位置,更多内容参考文档
四. 实现富文本
4.1 定义SlateNode
对象原型
SlateNode
对象的tag
属性代表对应DOM
节点标签,如p
、span
或h1
等等,flags
属性代表副作用,如加粗,斜体等
ELEMENT_TO_NODE
用来记录DOM
节点与SlateNode
节点的映射关系,在渲染DOM
节点时会添加映射关系
typescript
// dom节点与slateNode节点映射关系
export const ELEMENT_TO_NODE: WeakMap<Node, SlateNode> = new WeakMap()
class SlateNode {
tag: keyof JSX.IntrinsicElements // 节点标签
key: string // 唯一标识
text: string // 文本内容
children: SlateNode[] // 子节点
parent: SlateNode | null // 父节点
stateNode: HTMLElement | null // DOM节点
path: number[] // 节点路径
bold?: boolean // 是否加粗
code?: boolean // 是否是代码块
italic?: boolean // 是否是斜体
underline?: boolean // 是否有下划线
align?: 'left' | 'center' | 'right' // 文本水平位置
flags: number // 副作用,如加粗,斜体
attributes: {
className?: string // class类名
style?: CSSProperties // style样式
contentEditable?: boolean // 是否可编辑
}
constructor({
tag = 'div',
key = uniqueId(),
text = '',
children = [],
parent = null,
stateNode = null,
path = [],
bold,
code,
italic,
underline,
align,
flags = NoFlags,
attributes = {},
}: Partial<SlateNode>) {
this.tag = tag
this.key = key
this.text = text
this.children = children
this.parent = parent
this.stateNode = stateNode
this.path = path
this.bold = bold
this.code = code
this.italic = italic
this.underline = underline
this.align = align
this.flags = flags
this.attributes = attributes
}
}
4.2 定义SlateEditor
对象
SlateEditor
对象的domEl
属性记录编辑容器节点,通常是div
,slateRootNode
属性指向SlateNode Tree
根节点,slateSelection
属性记录光标选择区域
提供文本操作相关API
,如插入文本insertText
方法或删除文本deleteContentBackward
方法等等
typescript
class SlateEditor {
domEl: HTMLElement | null // 编辑容器节点
slateRootNode: SlateNode // SlateNode Tree根节点
slateSelection: SlateSelection | null // 对标Selection对象,有两个属性,anchor是选择区域开始锚点,focus是选择区域结束锚点
flags: number // 副作用,如加粗,斜体
forceUpdate: ActionDispatch<AnyActionArg> // 触发更新渲染
constructor({
domEl = null,
slateSelection = null,
slateRootNode = new SlateNode({ tag: 'div' }),
flags = NoFlags,
forceUpdate = noop,
}: Partial<SlateEditor>) {
this.domEl = domEl
this.slateSelection = slateSelection
this.slateRootNode = slateRootNode
this.flags = flags
this.forceUpdate = forceUpdate
}
// 插入文本
public insertText(text: string) {}
// 插入行
public insertParagraph() {}
// 删除文本
public deleteContentBackward() {}
// 处理操作栏操作逻辑
public addFlag(flag: number) {}
}
4.3 定义createEditor
方法
创建SlateEditor
对象实例方法,initialValue
入参是富文本初始内容,如果没有传则渲染占位节点
typescript
const renderPlaceholder = (): SlateNode =>
new SlateNode({
tag: 'p',
children: [
// \uFEFF代表非法字符即不可用字符,不会在页面上展示,作用是判断当前行是否为空
new SlateNode({ tag: 'span', text: '\uFEFF' }),
new SlateNode({
tag: 'span',
text: 'Enter some rich text...',
attributes: {
style: {
position: 'absolute',
top: 0,
left: 0,
opacity: 0.3,
pointerEvents: 'none', // 忽略鼠标点击事件
userSelect: 'none', // 内容不可选中
},
contentEditable: false,
}),
],
})
function createEditor(initialValue?: SlateNode[]): SlateEditor {
const editor = new SlateEditor({
slateRootNode: new SlateNode({
tag: 'div',
children: initialValue || [renderPlaceholder()],
}),
})
return editor
}
4.4 定义Slate
组件
负责初始化SlateEditor
对象实例,赋值给SlateContext
。提供forceUpdate
更新渲染方法赋值给SlateEditor
对象
typescript
function Slate({ initialValue }: { initialValue?: SlateNode[] }) {
const [, forceUpdate] = useReducer((s) => s + 1, 0)
const editor = useMemo(() => createEditor(initialValue), [])
useEffect(() => {
editor.forceUpdate = forceUpdate
return () => {
editor.forceUpdate = noop
}
}, [])
return (
<SlateContext.Provider value={{ editor }}>
<div className='editable-container'>
{/* 操作栏 */}
<Toolbar />
{/* 编辑区 */}
<Editable />
</div>
</SlateContext.Provider>
)
}
4.5 操作栏Toolbar
4.5.1 定义Icon
组件
提供修改字体样式的操作,如加粗、斜体或代码块等等
4.5.1.1 引入图标字体
css
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/materialicons/v143/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2)
format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
4.5.1.2 实现Icon
组件
flag
参数代表副作用,如加粗,斜体等,会记录到editor.flags
属性上
typescript
function Icon(props: PropsWithChildren<{ flag: number }>) {
const editor = useSlate()
const { flag, ...rest } = props
return (
<span
onClick={() => {
editor.addFlag(flag)
}}
className={`material-icons editable-icon ${editor.flags & flag ? 'editable-icon-active' : ''}`}
{...rest}
/>
)
}
4.5.2 定义Toolbar
组件
typescript
function Toolbar() {
return (
<div className='editable-toolbar'>
{/* 加粗 */}
<Icon flag={Bold}>format_bold</Icon>
{/* 斜体 */}
<Icon flag={Italic}>format_italic</Icon>
{/* 下划线 */}
<Icon flag={Underline}>format_underlined</Icon>
{/* 代码块 */}
<Icon flag={Code}>code</Icon>
{/* h1 */}
<Icon flag={HeadingOne}>looks_one</Icon>
{/* h2 */}
<Icon flag={HeadingTwo}>looks_two</Icon>
{/* blockquote */}
<Icon flag={BlockQuote}>format_quote</Icon>
{/* ol */}
<Icon flag={NumberedList}>format_list_numbered</Icon>
{/* ul */}
<Icon flag={BulletedList}>format_list_bulleted</Icon>
{/* 文本居左 */}
<Icon flag={TextAlignLeft}>format_align_left</Icon>
{/* 文本居中 */}
<Icon flag={TextAlignCenter}>format_align_center</Icon>
{/* 文本居右 */}
<Icon flag={TextAlignRight}>format_align_right</Icon>
{/* 文本自适应 */}
<Icon flag={TextAlignJustify}>format_align_justify</Icon>
</div>
)
}
4.6 编辑区
通过给div
节点添加contentEditable
属性实现内容可编辑
typescript
function Editable() {
const editor = useSlate()
return (
<div
ref={(el) => {
editor.domEl = el
return () => {
editor.domEl = null
}
}}
className='editable-content'
contentEditable
suppressContentEditableWarning
data-slate-editor
>
<Children />
</div>
)
}
4.6.1 监听selectionchange
事件
通过document.getSelection
方法获取Selection
对象,创建其对应的SlateSelection
对象,主要记录光标选择区域开始锚点和结束锚点
typescript
function Editable() {
useEffect(() => {
const onDOMSelectionChange = () => {
const domSelection = document.getSelection()
if (!domSelection) return
const { anchorNode, focusNode } = domSelection
if (
editor.domEl?.contains(anchorNode) &&
editor.domEl?.contains(focusNode)
) {
editor.slateSelection = SlateSelection.toSlateSelection(domSelection)
editor.bubbleProperties()
editor.forceUpdate()
}
}
document.addEventListener('selectionchange', onDOMSelectionChange)
return () => {
document.removeEventListener('selectionchange', onDOMSelectionChange)
}
}, [])
}
4.6.2 监听beforeinput
事件
通过event
对象的inputType
属性可以知道用户操作类型,如insertText
代表插入文本
typescript
function Editable() {
useEffect(() => {
const onDOMBeforeInput = (event: InputEvent) => {
event.preventDefault()
switch (event.inputType) {
// 插入文本
case 'insertText':
if (!event.data) return
editor.insertText(event.data)
break
// 插入行
case 'insertParagraph':
editor.insertParagraph()
break
// 删除文本
case 'deleteContentBackward':
editor.deleteContentBackward()
break
}
}
editor.domEl?.addEventListener('beforeinput', onDOMBeforeInput)
return () => {
editor.domEl?.removeEventListener('beforeinput', onDOMBeforeInput)
}
}, [])
...
}
4.6.3 设置光标选择区域
由于在beforeinput
事件中调用了event.preventDefault
方法阻止了浏览器默认行为,所以需要手动设置光标位置
typescript
function Editable() {
useEffect(() => {
// 获取Selection对象
const selection = document.getSelection()
if (!selection || !editor.slateSelection) return
// 获取StaticRange对象
const range = SlateSelection.toDOMRange(
editor.slateRootNode,
editor.slateSelection,
)
const { startContainer, startOffset, endContainer, endOffset } = range
// 设置光标选择区域
selection.setBaseAndExtent(
startContainer,
startOffset,
endContainer,
endOffset,
)
})
...
}
4.6.4 渲染子节点
path
属性值是number
类型数组,代表该节点在SlateNode Tree
中的位置,如path
为[0, 0]
,说明该节点在第一行第一列位置,通过该path
属性也可以获取其对应的DOM
节点
typescript
function useChildren({ node }: { node: SlateNode }) {
const children = []
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]
// 计算节点路径
child.path = node.tag === 'div' ? [i] : node.path.concat([i])
// 建立父子关系索引
child.parent = node
if (child.tag === 'span') {
// 行内元素节点
children.push(<TextComponent key={child.key} node={child} />)
} else {
// 块级元素节点
children.push(<ElementComponent key={child.key} node={child} />)
}
}
return children
}
function Children() {
const editor = useSlate()
return useChildren({ node: editor.slateRootNode })
}
4.6.4.1 定义Element
组件
typescript
function renderElement({
attributes,
node,
children,
}: PropsWithChildren<{
attributes: { [key: string]: unknown }
node: SlateNode
}>) {
const style = { textAlign: node.align }
// 元素标签
const Component = node.tag
return (
<Component style={style} {...attributes}>
{children}
</Component>
)
}
function Element({ node }: { node: SlateNode }) {
const children = useChildren({ node })
const attributes = {
...node.attributes,
'data-slate-node': 'element',
ref(el: HTMLElement) {
// 记录DOM节点
node.stateNode = el
// 记录DOM节点和SlateNode节点映射关系
ELEMENT_TO_NODE.set(el, node)
return () => {
node.stateNode = null
ELEMENT_TO_NODE.delete(el)
}
},
}
return renderElement({ attributes, children, node })
}
4.6.4.2 定义Text
组件
typescript
function renderText({
attributes,
node,
children,
}: PropsWithChildren<{
attributes: { [key: string]: unknown }
node: SlateNode
}>) {
if (node.bold) {
children = <strong>{children}</strong>
}
if (node.code) {
children = <code>{children}</code>
}
if (node.italic) {
children = <em>{children}</em>
}
if (node.underline) {
children = <u>{children}</u>
}
return <span {...attributes}>{children}</span>
}
function Text({ node }: { node: SlateNode }) {
const attributes = {
...node.attributes,
'data-slate-node': 'text',
ref(el: HTMLElement) {
// 记录DOM节点
node.stateNode = el
// 记录DOM节点和SlateNode节点映射关系
ELEMENT_TO_NODE.set(el, node)
return () => {
node.stateNode = null
ELEMENT_TO_NODE.delete(el)
}
},
}
return renderText({
attributes,
node,
children: <span data-slate-leaf>{node.text}</span>,
})
}
4.7 实现SlateEditor API
4.7.1 insertText
判断当前行是否以\uFEFF
开头,是则说明当前行没有输入内容,直接替换成输入文本内容即可,不是则对当前文本进行截取,将输入内容插入到当前文本中
typescript
class SlateEditor {
public insertText(text: string) {
// 移除占位节点
this.removePlaceholder()
// anchor为选择区域开始锚点,focus为选择区域结束锚点
const { anchor, focus } = this.slateSelection!
// 获取开始锚点对应的SlateNode节点
const slateNode = SlateNode.toSlateNodeByPath(
this.slateRootNode,
anchor.path,
)
// 如果文本内容以\uFEFF开头,直接替换成输入文本内容即可
if (slateNode.isEmpty()) {
slateNode.text = text
this.forceUpdate()
return
}
// 获取光标前半部分文本
const before = slateNode.text.slice(0, anchor.offset)
// 获取光标后半部分文本
const after = slateNode.text.slice(anchor.offset)
// 将输入文本插入到光标位置
slateNode.text = before + text + after
anchor.offset += 1
focus.offset += 1
this.forceUpdate()
}
}
4.7.2 insertParagraph
判断光标位置后面是否有文本,有则需要将其添加到新行中,并将其从旧行中删除
typescript
class SlateEditor {
public insertParagraph() {
// 移除占位节点
this.removePlaceholder()
const { anchor, focus } = this.slateSelection!
// 获取换行文本内容
let text = ''
// 开始锚点对应的SlateNode节点
const slateNode = SlateNode.toSlateNodeByPath(
this.slateRootNode,
anchor.path,
)
text += slateNode.text.slice(anchor.offset)
slateNode.text = slateNode.text.slice(0, anchor.offset)
const parentNode = slateNode.parent!
const childrenList = parentNode.children.splice(
slateNode.path[slateNode.path.length - 1] + 1,
)
childrenList.unshift(renderText({ flags: slateNode.flags, text }))
// 如果当前行文本为空则赋值为\uFEFF
if (parentNode.children.length === 1 && !parentNode.children[0].text)
parentNode.children = renderParagraph().children
// 新行坐标
const paragraphLocation = parentNode.path[parentNode.path.length - 1] + 1
// 当前行根节点
const rootNode = parentNode.parent!
const before = rootNode.children.slice(0, paragraphLocation)
const after = rootNode.children.slice(paragraphLocation)
const children = ['ol', 'ul'].includes(rootNode.tag)
? renderListItem({
flags: parentNode.flags,
children: childrenList,
path: [...rootNode.path, paragraphLocation],
})
: renderParagraph({
tag: parentNode.tag,
flags: parentNode.flags,
children: childrenList,
path: [...rootNode.path, paragraphLocation],
})
rootNode.children = [...before, children, ...after]
anchor.path = focus.path = [...children.path, 0]
anchor.offset = focus.offset = 0
this.forceUpdate()
}
}
4.7.3 deleteContentBackward
如果是在行开头进行删除操作,判断光标位置后面是否有文本,有则需要将其添加到上一行末尾,并将其从旧行中删除
typescript
class SlateEditor {
public deleteContentBackward() {
if (this.isEmpty()) return
const { anchor, focus } = this.slateSelection!
let slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, anchor.path)
const parentNode = slateNode.parent!
const rootNode = parentNode.parent!
// 当前行开头删除处理逻辑
if (slateNode.isEmpty() || !anchor.offset) {
// 在首行开头则直接跳过
if (anchor.path.every((p) => p === 0)) return
// 当前行坐标
const paragraphLocation = parentNode.path[parentNode.path.length - 1]
const [paragraph] = rootNode.children.splice(paragraphLocation, 1)
if (!rootNode.children.length)
this.slateRootNode.children.splice(rootNode.path[0], 1)
// 获取当前行文本内容
const text = paragraph.isEmpty() ? '' : paragraph.string()
const prevParagraph =
paragraphLocation === 0
? this.slateRootNode.children[rootNode.path[0] - 1]
: rootNode.children[paragraphLocation - 1]
let lastChild = prevParagraph
while (lastChild.tag !== 'span')
lastChild = lastChild.children[lastChild.children.length - 1]
lastChild.text =
text && lastChild.isEmpty() ? text : lastChild.text + text
if (this.isEmpty()) this.renderPlaceholder()
anchor.path = focus.path = lastChild.path
anchor.offset = focus.offset = lastChild.text.length - text.length
this.bubbleProperties()
this.forceUpdate()
return
}
// 获取光标前半部分文本
const before = slateNode.text.slice(0, anchor.offset - 1)
// 获取光标后半部分文本
const after = slateNode.text.slice(anchor.offset)
slateNode.text = before + after
if (slateNode.text) {
anchor.offset -= 1
focus.offset -= 1
this.forceUpdate()
return
}
parentNode.children.splice(parentNode.children.indexOf(slateNode), 1)
if (!parentNode.children.length) {
if (this.isEmpty()) this.renderPlaceholder()
else parentNode.children = renderParagraph().children
anchor.offset = focus.offset = 1
} else {
// 移动到上一个子节点
anchor.path[anchor.path.length - 1] -= 1
focus.path[focus.path.length - 1] -= 1
slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, anchor.path)
anchor.offset = focus.offset = slateNode.text.length
}
this.bubbleProperties()
this.forceUpdate()
}
}
4.7.4 addFlag
运用位运算技巧添加或删除相关的操作,例如this.flags |= Bold
代表添加加粗操作,this.flags &= ~Bold
代表移除加粗操作
对于SlateNode Tree
主要有如下三种处理逻辑
- 第一种是
textAlign
,即控制文本水平位置,添加style
对象对应的textAlign
属性值即可 - 第二种是行内元素标签,如
strong
、em
或u
等,需要修改SlateNode
节点的flags
属性值,根据flags
属性值修改SlateNode
节点的bold
、italic
、underline
、code
属性 - 第三种是块级元素标签,如
h1
、h2
或blockquote
等,需要更改SlateNode
节点的tag
属性,如果是涉及列表元素,还需要调整子树结构
typescript
class SlateEditor {
public addFlag(flag: number) {
// 移除占位节点
this.removePlaceholder()
const { anchor, focus } = this.slateSelection!
switch (flag) {
case TextAlignLeft:
case TextAlignCenter:
case TextAlignRight:
case TextAlignJustify: {
const slateNode = SlateNode.toSlateNodeByPath(
this.slateRootNode,
anchor.path,
)
const parentNode = slateNode.parent!
parentNode.addFlag(flag, HasTextAlign)
parentNode.setProps(['textAlign'])
break
}
case Bold:
case Italic:
case Underline:
case Code: {
const slateNode = SlateNode.toSlateNodeByPath(
this.slateRootNode,
anchor.path,
)
slateNode.addFlag(flag, NoFlags)
slateNode.setProps(['bold', 'italic', 'underline', 'code'])
break
}
case HeadingOne:
case HeadingTwo:
case BlockQuote:
case NumberedList:
case BulletedList: {
let tag: keyof JSX.IntrinsicElements = 'p'
switch (flag) {
case HeadingOne:
tag = 'h1'
break
case HeadingTwo:
tag = 'h2'
break
case BlockQuote:
tag = 'blockquote'
break
case NumberedList:
tag = 'ol'
break
case BulletedList:
tag = 'ul'
break
}
const slateNode = SlateNode.toSlateNodeByPath(this.slateRootNode, [
anchor.path[0],
])
if (['p', 'h1', 'h2', 'blockquote'].includes(slateNode.tag)) {
slateNode.addFlag(flag, HasBlockElement)
slateNode.tag = slateNode.flags & flag ? tag : 'p'
if (['ol', 'ul'].includes(slateNode.tag)) {
slateNode.children = [
renderListItem({
flags: slateNode.flags & HasTextAlign,
children: slateNode.children,
}),
]
slateNode.flags &= ~HasTextAlign
slateNode.setProps(['textAlign'])
}
break
}
const parentIndex = anchor.path[0]
const childIndex = anchor.path[1]
let children = slateNode.children[childIndex]
const childrenList = slateNode.children.splice(childIndex + 1)
// 移除当前li节点
slateNode.children.pop()
// 如果当前节点tag和新tag一致,则将该li元素切换成p元素
if (slateNode.tag === tag) {
children.tag = 'p'
children.addFlag(NoFlags, HasBlockElement)
}
// 如果新tag属于h1、h2、blockquote,那切换成对应的tag元素
else if (['h1', 'h2', 'blockquote'].includes(tag)) {
children.tag = tag
children.addFlag(flag, HasBlockElement)
} else {
children = renderList({
tag: tag,
flags: tag === 'ol' ? NumberedList : BulletedList,
children: [children],
})
}
this.slateRootNode.children = [
...this.slateRootNode.children.slice(0, parentIndex),
...(slateNode.children.length ? [slateNode] : []),
children,
...(childrenList.length
? [
renderList({
tag: slateNode.tag,
flags: slateNode.flags,
children: childrenList,
}),
]
: []),
...this.slateRootNode.children.slice(parentIndex + 1),
]
anchor.path = focus.path = [
slateNode.children.length ? parentIndex + 1 : parentIndex,
0,
...(slateNode.tag === tag || ['h1', 'h2', 'blockquote'].includes(tag)
? []
: [0]),
]
break
}
}
// 收集子树副作用
this.bubbleProperties()
if (this.flags === NoFlags && this.isEmpty()) this.renderPlaceholder()
this.forceUpdate()
}
}
五. 结束语
我们通过将富文本内容转化成一棵虚拟DOM
树,根据用户输入操作类型修改虚拟DOM
树节点数据,然后触发更新渲染,展示新的富文本内容
对于操作栏操作记录,通过位运算技巧进行判断,可以高效添加或删除副作用。
创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!