一. 前言
二. 核心对象
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树节点数据,然后触发更新渲染,展示新的富文本内容
对于操作栏操作记录,通过位运算技巧进行判断,可以高效添加或删除副作用。
创作不易,如果文章对你有帮助,那点个小小的赞吧,你的支持是我持续更新的动力!