背景
在开发业务需求时,遇到一个富文本的功能开发。原本以为像富文本这种实现,业内肯定有很成熟的方案来实现。但只要涉及到功能的定制,就十分不易开发。本文是基于 Slate 开发的富文本编辑器,将详细介绍如何解决 鼠标光标在富文本编辑器中定位 及 富文本编辑器多实例管理 的问题。
准备
可以参考 Slate 文档 开发基础的富文本编辑器功能
bash
yarn add slate slate-react
需求描述
- 富文本在使用中,分两种状态
编辑状态和纯展示状态,是有两种不同的 UI - 当用户点击
纯展示状态下的文字时,编辑器要转换成编辑状态,且光标要自动定位到点击的文字上 - 同一页面会有多个富文本实例,但始终
只能有一个实例处于编辑状态

分析
- 展示两种不同的 UI 很好实现,无非就是写个逻辑判断 { canEditor ? <编辑状态 UI /> : <展示状态 UI />}
- 得记录鼠标的位置,在切换编辑器状态后,根据记录的位置,手动更新光标的位置
- 需要用到类组件,并维护一个实例队列,遍历确定每个实例的状态
解决方案
光标定位
为了解决鼠标光标在富文本编辑器中定位的问题,我们首先需要记录用户点击或选中文本时的位置信息。然后,在 编辑状态 下,将鼠标光标移动到正确的位置。
记录鼠标选择的位置信息
在 展示状态 下,用户点击文本时,我们需要记录鼠标选择的位置信息。这里,我们使用 recordSelection 方法来实现这个功能:
ts
// 获取鼠标选择的位置信息
const getSelectionRecord = useCallback((): SelectionRecord => {
if (edit) return window.__selectionRecord;
return selectionRecordRef.current;
}, [edit]);
// 获取当前的Selection
const getSelection = useCallback(() => {
if (edit) return (document.querySelector('#iframe-root') as HTMLIFrameElement)?.contentWindow?.getSelection();
return window.getSelection();
}, [edit]);
const recordSelection = useCallback(() => {
const selection = getSelection();
const { anchorNode, anchorOffset } = selection || {};
if (!anchorNode?.parentNode) {
setSelectionRecord(null);
return;
}
let node = anchorNode.parentNode as Node;
let index = 0;
while ((node = node.previousSibling as Node) !== null) index += 1;
setSelectionRecord([index, anchorOffset || 0]);
}, [getSelection, setSelectionRecord]);
在 recordSelection 方法中,我们首先获取当前的Selection对象。然后,从Selection对象中获取 anchorNode 和 anchorOffset 属性。anchorNode 表示鼠标选择的起始节点,anchorOffset 表示鼠标选择的起始位置相对于 anchorNode 的偏移量。
接下来,我们遍历 anchorNode 的所有前一个兄弟节点,计算出 anchorNode 在其父节点中的索引(index)。最后,我们将 index 和 anchorOffset 作为鼠标选择的位置信息,存储到 selectionRecordRef 中。
更新鼠标光标到富文本编辑器中
在切换成 编辑状态 时,我们需要将鼠标光标移动到正确的位置。这里,我们使用 updateMouseCursor 方法来实现这个功能:
ts
const updateMouseCursor = useCallback(() => {
setTimeout(() => {
const selectionRecord = getSelectionRecord();
if (!selectionRecord) return;
const [index, anchorOffset] = selectionRecord;
const pElement = richTextRef.current?.querySelectorAll('.cloud-sidebar__rich-text__editor-wrap>div:not(.cloud-sidebar__rich-text__editor-placeholder)>p',)?.[index];
const text = pElement?.querySelector('span>span>span')?.childNodes?.[0];
if (!text) return;
const selection = getSelection();
selection?.collapse(text, Math.min(anchorOffset, (text as Text).length));
});
}, [getSelectionRecord, getSelection]);
在 updateMouseCursor 方法中,我们首先获取之前记录的鼠标选择的位置信息(selectionRecord)。然后,根据 index 和anchorOffset,找到富文本编辑器中对应的文本节点(text)。
接下来,我们获取当前的Selection对象,并使用collapse方法将鼠标光标移动到text节点的 anchorOffset 位置。这样,当用户开始编辑文本时,操作将应用在正确的位置。
需要注意的是,我们在 updateMouseCursor 方法中使用了 setTimeout。这是因为在某些情况下,浏览器可能需要一点时间来渲染富文本编辑器。通过将 updateMouseCursor 放入事件循环的下一轮,我们确保了富文本编辑器 切换状态 的渲染已经完成,从而可以正确地定位鼠标光标。
整合到富文本编辑器
接下来,我们需要将上述方法整合到富文本编辑器中。首先,在用户点击 展示状态 时,我们调用 recordSelection 方法记录鼠标选择的位置信息。然后,设置 canEditor 状态为 true (切换成 编辑状态),并调用 changeInteractive 方法。
ts
const onClick = () => {
setCanEditor(true);
recordSelection();
changeInteractive();
};
在useEffect中,我们根据showRichText状态决定是否显示富文本编辑器。当showRichText为true时,我们调用updateMouseCursor方法将鼠标光标移动到正确的位置。
ts
useEffect(() => {
onChangeStatus?.(showRichText ? 'input' : 'default');
if (showRichText) updateMouseCursor();
return () => onChangeStatus('default');
}, [showRichText]);
这样,我们就实现了鼠标光标在富文本编辑器中的定位功能。
只能有一个实例处于编辑状态
我们通过 函数式组件 实现了富文本编辑器,但是如果涉及到多个富文本的状态切换,还是需要借助 类组件 的方式,结构类似于:
ts
class RichTextClass extends React.Component {
render() {
<RichText {...props} />
}
}
我们需要在类组件中定义一个实例数组,每创建一个富文本编辑器,都要把组件实例push到数组中
ts
static instanceList: ToolbarRichText[] = [];
componentDidMount() {
RichTextClass.instanceList.push(this);
}
再定义一个 change 方法,用于感知 函数式组件 的状态,是 编辑状态 还是 展示状态
ts
changeInteractive = () => {
RichTextClass.instanceList.forEach((instance) => {
const interactive = instance === this ? 'focus' : 'blur';
if (interactive === instance.state.interactive) return;
instance.setState({ interactive });
});
};
interactive 属性会透传给函数式的编辑器组件,用于控制状态的切换
ts
class RichTextClass extends React.Component {
render() {
<RichText
{...props}
interactive={interactive}
changeInteractive={changeInteractive}
/>
}
}
所以整个流程是:当用户第一次点击富文本编辑器组件时,触发 changeInteractive 方法,该方法会遍历所有创建的 富文本实例,把除用户点击的富文本外的所有实例,都设置成 展示状态,被点击的实例设置成 编辑状态
总结
虽然本文提供的解决方案可能较为简单,但它提供了一个基本思路。在实际项目中,大家可能需要根据具体需求和浏览器兼容性进行相应的调整。