键盘事件处理
因为 slate 抛弃了浏览器提供的 document.exceCommand 命令转而自己去实现通过操作数据渲染对应视图,那我们就一起看一下,在编辑器里,按下一个字母,slate 有什么操作使得数据控制视图。
我们直接使用源码里面提供的启动例子来做示例:
const RichTextExample = () => {
const renderElement = useCallback(props => <Element {...props} />, [])
const renderLeaf = useCallback(props => <Leaf {...props} />, [])
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
return (
<Slate editor={editor} value={initialValue}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
<BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" />
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
<BlockButton format="left" icon="format_align_left" />
<BlockButton format="center" icon="format_align_center" />
<BlockButton format="right" icon="format_align_right" />
<BlockButton format="justify" icon="format_align_justify" />
</Toolbar>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder="Enter some rich text…"
spellCheck
autoFocus
onKeyDown={event => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event as any)) {
event.preventDefault()
const mark = HOTKEYS[hotkey]
toggleMark(editor, mark)
}
}
}}
/>
</Slate>
)
}
视图相关的代码在 /slate-react
包中,提供了一个<Slate>
组件并接收一些初始状态,然后使用<Editable>
组件来呈现可编辑的文本。在<Editable>
组件内部,你可以定义自己的组件来渲染特定类型的节点。一些相关的事件绑定就在<Editable>
组件中:
// slate-react/src/components/editable.jsx
<Component
onBeforeInput={useCallback(
// ...
)}
onInput={useCallback(
// ...
)}
onBlur={useCallback(
// ...
)}
onClick={useCallback(
// ...
)}
onCompositionEnd={useCallback(
// ...
)}
onCompositionUpdate={useCallback(
// ...
)}
onCompositionStart={useCallback(
// ...
)}
onCopy={useCallback(
// ...
)}
onCut={useCallback(
// ...
)}
onDragOver={useCallback(
// ...
)}
onDragStart={useCallback(
// ...
)}
onDrop={useCallback(
// ...
)}
onDragEnd={useCallback(
// ...
)}
onFocus={useCallback(
// ...
)}
onKeyDown={useCallback(
// ...
)}
onPaste={useCallback(
// ...
)}
>
我在<Editable>
组件下按下一个字母,这时候会依次触发 onKeyDown 、onBeforeInput、onInput三个事件。
onKeyDown 事件用于处理非打印字符的按键事件,如回退、删除、方向键、回车键等等。
如在onKeyDown下面实现的撤销和还原功能:
if (Hotkeys.isRedo(nativeEvent)) {
event.preventDefault()
const maybeHistoryEditor: any = editor
if (typeof maybeHistoryEditor.redo === 'function') {
maybeHistoryEditor.redo()
}
return
}
if (Hotkeys.isUndo(nativeEvent)) {
event.preventDefault()
const maybeHistoryEditor: any = editor
if (typeof maybeHistoryEditor.undo === 'function') {
maybeHistoryEditor.undo()
}
return
}
onBeforeInput 事件在输入字符前触发。slate在其中做了两层处理:
const onDOMBeforeInput = useCallback((event) => {
// ...
let native = false
if (
type === 'insertText' &&
selection &&
Range.isCollapsed(selection) &&
event.data &&
event.data.length === 1 &&
/[a-z ]/i.test(event.data) &&
selection.anchor.offset !== 0
) {
native = false
// ...
}
if (!native) {
event.preventDefault()
}
Editor.insertText(editor, text)
// ...
})
const callbackRef = useCallback(() => {
// ...
if (HAS_BEFORE_INPUT_SUPPORT) { // 浏览器支持 beforeinput
node.addEventListener('beforeinput', onDOMBeforeInput)
}
})
<Component
ref={callbackRef}
onBeforeInput={useCallback(() => {
// ...
event.preventDefault()
if (!ReactEditor.isComposing(editor)) {
const text = (event as any).data as string
Editor.insertText(editor, text)
}
})}
/>
首先编辑一个事件处理函数:onDOMBeforeInput
,然后通过 callbackRef
中的 node.addEventListener('beforeinput', onDOMBeforeInput)
这行代码,onDOMBeforeInput
事件处理函数被直接添加到了原生的 DOM 节点上。
然后,编辑器元素的引用(ref)被传递给了 callbackRef
函数。这个函数会在元素被挂载或卸载时被调用。
除了绑定的 onDOMBeforeInput
原生事件,slate还在组件里监听了onBeforeInput
的react合成事件,它在某些浏览器(尤其是Firefox和IE)中作为 beforeinput
事件的polyfill。这个事件处理程序的主要目的是处理那些不支持 beforeinput
事件的浏览器。由于这些浏览器的限制,它只能处理 insertText
类型的输入事件。所以使用了两种方式来处理输入字符前事件。
并且在 onDOMBeforeInput
处理函数中,有一个特殊处理,当输入情况比较简单,如插入单个字符、空格且没有选区选中多个字符等情况下, native
为 true
,会执行 onBeforeInput
合成事件中的简单处理。这也是提高输入性能的一种手段。
在 onBeforeInput
事件处理中,使用了 event.preventDefault()
方法,在阻止默认事件发生,就是输入字母后不会直接打在输入框里,而是提前获取输入的键盘字母,然后通过slate提供的方法去操作数据,再根据数据渲染到页面上(具体方法后面会分析)。
除了这种简单的字母数字输入,Slate 会监听 compositionstart
,compositionupdate
和 compositionend
这三个事件来处理与输入法相关的行为。
const [isComposing, setIsComposing] = useState(false)
<Component
onCompositionStart={useCallback((event) => {
setIsComposing(true)
if (selection) {
if (Range.isExpanded(selection)) {
Editor.deleteFragment(editor)
return
}
const inline = Editor.above(editor, {
match: n =>
Element.isElement(n) && Editor.isInline(editor, n),
mode: 'highest',
})
if (inline) {
const [, inlinePath] = inline
if (Editor.isEnd(editor, selection.anchor, inlinePath)) {
const point = Editor.after(editor, inlinePath)!
Transforms.setSelection(editor, {
anchor: point,
focus: point,
})
}
}
}
)}
onCompositionUpdate={useCallback((event) => {
setIsComposing(true)
)}
onCompositionEnd={useCallback((event) => {
setIsComposing(false)
Editor.insertText(editor, event.data)
)}
}
首先当用户开始使用复合输入法输入文本的时候,会触发 onCompositionStart
方法,在方法中设置 isComposing
为 true
,这表示现在正处于一个输入法的组合输入过程中。并且检查当前是否有选中的区域,如果有的话:
- 如果当前选区是展开的,那么就删除选区内的内容,然后返回。
- 如果有内联元素的话,且当前的选区位于这个内联元素的末尾,那么就将选区移动到这个内联元素的后面。
onCompositionUpdate
事件在输入法生成新的组合文本时被触发。
onCompositionEnd
事件在用户结束使用输入法输入文本,例如选中候选词或者按下 Enter 键时被触发,将 isComposing
设置为 false
,并将选中的词通过 insertText 方法更新数据从而渲染界面。
修改数据模型
上文中可以看到,从发生键盘事件输入字符,slate获取到对应字符调用了Editor.insert方法:
// slate/scr/editor/insert-text.ts
export const insertText: EditorInterface['insertText'] = (
editor,
text,
options = {}
) => {
const { selection, marks } = editor
if (selection) {
if (marks) {
const node = { text, ...marks }
Transforms.insertNodes(editor, node, {
at: options.at,
voids: options.voids,
})
} else {
Transforms.insertText(editor, text, options)
}
editor.marks = null
}
}
从代码中可以看到,会根据 marks
来判断走 insertNodes
或者 insertText
。marks
代表的是一种文本样式或特性,比如是否加粗、是否斜体等等。我们插入的是一个纯文本,所以走的是 insertText
方法。
// /slate/scr/interfaces/transforms/text.ts
insertText(
editor: Editor,
text: string,
options: TextInsertTextOptions = {}
): void {
Editor.withoutNormalizing(editor, () => {
const { voids = false } = options
let { at = getDefaultInsertLocation(editor) } = options
if (Path.isPath(at)) {
at = Editor.range(editor, at)
}
if (Range.isRange(at)) {
if (Range.isCollapsed(at)) {
at = at.anchor
} else {
const end = Range.end(at)
if (!voids && Editor.void(editor, { at: end })) {
return
}
const start = Range.start(at)
const startRef = Editor.pointRef(editor, start)
const endRef = Editor.pointRef(editor, end)
Transforms.delete(editor, { at, voids })
const startPoint = startRef.unref()
const endPoint = endRef.unref()
at = startPoint || endPoint!
Transforms.setSelection(editor, { anchor: at, focus: at })
}
}
if (
(!voids && Editor.void(editor, { at })) ||
Editor.elementReadOnly(editor, { at })
) {
return
}
const { path, offset } = at
if (text.length > 0)
editor.apply({ type: 'insert_text', path, offset, text })
})
}
可以看到,在 insertText
方法中使用了一个 Editor.withoutNormalizing
方法去包裹起来,这个方法的作用其实类似数据库中的事务一样,确保方法内的所有操作作为一个整体的单一操作,类似事务:在事务完成之前,所有的变更都不会被其它人看到,当事务结束时,所有的变更都会一起被提交。如果事务不能成功完成,则所有的变更都会被回滚,就好像从未发生过一样。
在该方法中计算出了插入时的 path
和 offset
,合成一个 op
传入给 apply
方法。
最后更改数据模型的代码在这里 /slate/src/interface/transforms/general.ts
:
const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after // 修改当前node的text
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}