背景
在开发业务需求时,遇到一个富文本的功能开发。原本以为像富文本这种实现,业内肯定有很成熟的方案来实现。但只要涉及到功能的定制,就十分不易开发。本文是基于 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
方法,该方法会遍历所有创建的 富文本实例
,把除用户点击的富文本外的所有实例,都设置成 展示状态
,被点击的实例设置成 编辑状态
总结
虽然本文提供的解决方案可能较为简单,但它提供了一个基本思路。在实际项目中,大家可能需要根据具体需求和浏览器兼容性进行相应的调整。