slate源码解析(二):编辑流程

677 阅读5分钟

键盘事件处理

因为 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 处理函数中,有一个特殊处理,当输入情况比较简单,如插入单个字符、空格且没有选区选中多个字符等情况下, nativetrue ,会执行 onBeforeInput 合成事件中的简单处理。这也是提高输入性能的一种手段。

onBeforeInput 事件处理中,使用了 event.preventDefault() 方法,在阻止默认事件发生,就是输入字母后不会直接打在输入框里,而是提前获取输入的键盘字母,然后通过slate提供的方法去操作数据,再根据数据渲染到页面上(具体方法后面会分析)。

除了这种简单的字母数字输入,Slate 会监听 compositionstartcompositionupdatecompositionend 这三个事件来处理与输入法相关的行为。

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 方法,在方法中设置 isComposingtrue,这表示现在正处于一个输入法的组合输入过程中。并且检查当前是否有选中的区域,如果有的话:

  • 如果当前选区是展开的,那么就删除选区内的内容,然后返回。
  • 如果有内联元素的话,且当前的选区位于这个内联元素的末尾,那么就将选区移动到这个内联元素的后面。

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 或者 insertTextmarks 代表的是一种文本样式或特性,比如是否加粗、是否斜体等等。我们插入的是一个纯文本,所以走的是 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 方法去包裹起来,这个方法的作用其实类似数据库中的事务一样,确保方法内的所有操作作为一个整体的单一操作,类似事务:在事务完成之前,所有的变更都不会被其它人看到,当事务结束时,所有的变更都会一起被提交。如果事务不能成功完成,则所有的变更都会被回滚,就好像从未发生过一样。

在该方法中计算出了插入时的 pathoffset ,合成一个 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)!
    }
}