富文本编辑器框架 - slate.js

8,285 阅读10分钟

preview.png

图片来源:github.com/ianstormtay…

Slate 简介

Slate 是一个使用 TypeScript 开发的富文本编辑器开发框架,诞生于 2016 年,作者是 Ian Storm Taylor。Slate 是一个完全可定制的富文本编辑器框架。Slate 让你构建像 Medium, Dropbox Paper 或者是 Google Docs 这样丰富,直观的编辑器。你可以认为它是基于 React 的一种可拔插的 contenteditable 实现。它的灵感来源于 Draft.jsProsemirrorQuill 这样的库。slate 比较知名的用户有 GitBook语雀等。

Slate 在线 Demo

特点

  • 插件作为一等公民,能够完全修改编辑器行为;
  • 数据层和渲染层分离,更新数据触发渲染;
  • 文档数据类似于 DOM 树,可嵌套;
  • 具有原子化操作 API,支持协同编辑;
  • 使用 React 作为渲染层;

slate 架构简介

架构图

未命名绘图 (14).png

在 slate 代码仓库下包含四个 package 包:

  1. Slate History: 历史插件,提供了undo/redo支持;
  2. slate-hyperscript: 能够使用 JSX 语法来创建 slate 的数据;
  3. slate-react: 视图层;
  4. slate: 编辑器核心抽象,定义了 Editor,Path,Node,Text,Operation 等基础类,Transforms 操作;

WeChat86c41084f9e8169093c53fb1d3201919.png

slate (model)

slate package 是 slate 的核心,定义了编辑器的数据模型、操作这些模型的基本操作、以及创建编辑器实例对象的方法。

未命名绘图 (15).png

Interfaces

intefaces 目录下是 Slate 定义的数据模型,定义了 editor 、element、text、path、point、range、operation、location 等。

export type Node = Editor | Element | Text

export interface Element {
  children: Node[]
  [key: string]: unknown
}

export interface Text {
  text: string
  [key: string]: unknown
}
  • Editor Slate 的顶级节点就是 Editor 。它封装了文档的所有富文本内容,但是对于节点来说最重要的部分是它的 children 属性,其中包含一个 Node 对象树。
  • Element 类型含有 children 属性。
  • Text 文本节点是树中的最低级节点,包含文档的文本内容以及任何格式。

用户可以自行拓展 Node 的属性,例如通过添加 type 字段标识 Node 的类型(paragraph, ordered list, heading 等等),或者是文本的属性(italic, bold 等等),来描述富文本中的文字和段落。

截屏2021-06-02 上午8.11.47.png

路径(Path)

路径是引用一个位置的最底层方式。每个路径都是一个简单的数字数组,它通过文档树中每个祖先节点的索引来引用一个节点:

type Path = number[]

截屏2021-06-03 上午7.27.29.png

点(Point)

Point 包含一个 offset 属性(偏移量)对于特定的文本节点:

interface Point {
  path: Path
  offset: number
  [key: string]: unknown
}

文档范围(Range)

文档范围不仅指文档中的一个点,它是指两点之间的内容。

interface Range {
  anchor: Point
  focus: Point
  [key: string]: unknown
}

锚点焦点是通过用户交互建立的。锚点并不一定在焦点的 前面。就像在 DOM 一样,锚点和焦点的排序取决于选区的方向(向前或向后)。

Operation

Operation 对象是 Slate 用来更改内部状态的低级指令,作为文档的最小抽象, Slate 将所有变化表示为 Operation。你可以从 这里 看到源码。

export interface OperationInterface {
  isNodeOperation: (value: any) => value is NodeOperation
  isOperation: (value: any) => value is Operation
  isOperationList: (value: any) => value is Operation[]
  isSelectionOperation: (value: any) => value is SelectionOperation
  isTextOperation: (value: any) => value is TextOperation
  inverse: (op: Operation) => Operation
}


export const Operation: OperationInterface = {

  .....
  
  isOperation(value: any): value is Operation {
    if (!isPlainObject(value)) {
      return false
    }

    switch (value.type) {
      case 'insert_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
      case 'insert_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
      case 'merge_node':
        return (
          typeof value.position === 'number' &&
          Path.isPath(value.path) &&
          isPlainObject(value.properties)
        )
      case 'move_node':
        return Path.isPath(value.path) && Path.isPath(value.newPath)
      case 'remove_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
      case 'remove_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
      case 'set_node':
        return (
          Path.isPath(value.path) &&
          isPlainObject(value.properties) &&
          isPlainObject(value.newProperties)
        )
      case 'set_selection':
        return (
          (value.properties === null && Range.isRange(value.newProperties)) ||
          (value.newProperties === null && Range.isRange(value.properties)) ||
          (isPlainObject(value.properties) &&
            isPlainObject(value.newProperties))
        )
      case 'split_node':
        return (
          Path.isPath(value.path) &&
          typeof value.position === 'number' &&
          isPlainObject(value.properties)
        )
      default:
        return false
    }
  },
  
  ......
  
}

从上面的代码中可以看出,Operation 类型有 9 个:

  • insert_node:插入一个 Node。 包含 插入位置(path),插入节点(node)信息
 case 'insert_node':
        return Path.isPath(value.path) && Node.isNode(value.node)
  • insert_text:插入一段文本,所在 节点(path),插入内容(text),偏移量(offset)
case 'insert_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
  • merge_node:将两个 Node 组合成一个,包含待合并的 节点(path),合并目的地位置(position),合并后节点属性(properties)信息。
case 'merge_node':
        return (
          typeof value.position === 'number' &&
          Path.isPath(value.path) &&
          isPlainObject(value.properties)
        )
  • move_node:移动 Node,包含 移动位置(path),移动目的地(newPath)信息
case 'move_node':
 return Path.isPath(value.path) && Path.isPath(value.newPath)
  • remove_node:移除 Node,包含 删除位置(path),删除节点(node)信息
 case 'remove_node':
  return Path.isPath(value.path) && Node.isNode(value.node)
  • remove_text:移除文本,包含 所在节点(path),删除内容(text),偏移量(offset)信息
case 'remove_text':
        return (
          typeof value.offset === 'number' &&
          typeof value.text === 'string' &&
          Path.isPath(value.path)
        )
  • set_node:设置 Node 属性,包含 所在节点(path),被设置节点(node),节点属性(properties)信息
case 'set_node':
        return (
          Path.isPath(value.path) &&
          isPlainObject(value.properties) &&
          isPlainObject(value.newProperties)
        )
  • set_selection:设置选区位置,包含 新旧节点属性(properties,newProperties)信息
case 'set_selection':
        return (
          (value.properties === null && Range.isRange(value.newProperties)) ||
          (value.newProperties === null && Range.isRange(value.properties)) ||
          (isPlainObject(value.properties) &&
            isPlainObject(value.newProperties))
        )
  • split_node:拆分 Node ,包含 所在节点(path),节点位置(position),节点属性(properties)信息
case 'split_node':
        return (
          Path.isPath(value.path) &&
          typeof value.position === 'number' &&
          isPlainObject(value.properties)
        )

Transforms

Transforms 是对文档进行操作的辅助函数,包括选区转换节点转换文本转换通用转换。你可以从 这里 看到源码。

export const Transforms: GeneralTransforms &
  NodeTransforms &
  SelectionTransforms &
  TextTransforms = {
  ...GeneralTransforms,// 操作 Operation 指令
  ...NodeTransforms,// 操作节点指令
  ...SelectionTransforms,// 操作选区指令
  ...TextTransforms,// 操作文本指令
}

GeneralTransforms 比较特殊,它并不生成 Operation ,而是对 Operation 进行处理,只有它能直接修改 model,其他 transforms 最终都会转换成 GeneralTransforms 中的一种。

createEditor

创建编辑器实例的方法,返回一个实现了 Editor 接口的编辑器实例对象。你可以从 这里 看到源码。

export const createEditor = (): Editor => {
  const editor: Editor = {
    .....
  }
 
  return editor
}

更新 model

对 model 进行变更的过程主要分为以下两步:

  1. 通过 Transforms 提供的一系列方法生成 Operation
  2. Operation 进入 apply 流程

在 Operation apply 流程中有4 个主要步骤:

  1. 记录变更脏区
  2. 对 Operation 进行 transform
  3. 对 model 正确性进行校验
  4. 触发变更回调

Transforms.insertText 为例,你可以从 这里 看到源码。

export const TextTransforms: TextTransforms = {
  .....
  
   insertText(
    editor: Editor,
    text: string,
    options: {
      at?: Location
      voids?: boolean
    } = {}
  ): void {
     Editor.withoutNormalizing(editor, () => {
       const { voids = false } = options
      let { at = editor.selection } = options
      .....
      
      const { path, offset } = at
      if (text.length > 0)
        editor.apply({ type: 'insert_text', path, offset, text })
     })
  }
}

Transforms.insertText 的最后生成了一个 type 为 insert_text 的 Operation 并调用 Editor 实例的 apply 方法。

editor.apply 方法

你可以从 这里 看到源码。

export const createEditor = (): Editor => {
  const editor: Editor ={
    children: [],
    operations: [],
    selection: null,
    marks: null,
    isInline: () => false,
    isVoid: () => false,
    onChange: () => {},
     apply: (op: Operation) => {
      for (const ref of Editor.pathRefs(editor)) {
        PathRef.transform(ref, op)
      }

      for (const ref of Editor.pointRefs(editor)) {
        PointRef.transform(ref, op)
      }

      for (const ref of Editor.rangeRefs(editor)) {
        RangeRef.transform(ref, op)
      }

      const set = new Set()
      const dirtyPaths: Path[] = []

      const add = (path: Path | null) => {
        if (path) {
          const key = path.join(',')

          if (!set.has(key)) {
            set.add(key)
            dirtyPaths.push(path)
          }
        }
      }

      const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
      const newDirtyPaths = getDirtyPaths(op)

      for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
      }

      for (const path of newDirtyPaths) {
        add(path)
      }

      DIRTY_PATHS.set(editor, dirtyPaths)
      Transforms.transform(editor, op)
      editor.operations.push(op)
      Editor.normalize(editor)

      // Clear any formats applied to the cursor if the selection changes.
      if (op.type === 'set_selection') {
        editor.marks = null
      }

      if (!FLUSHING.get(editor)) {
        FLUSHING.set(editor, true)

        Promise.resolve().then(() => {
          FLUSHING.set(editor, false)
          editor.onChange()
          editor.operations = []
        })
      }
    },
    ......
  }
  return editor
}
转换坐标
for (const ref of Editor.pathRefs(editor)) {
    PathRef.transform(ref, op)
  }

  for (const ref of Editor.pointRefs(editor)) {
    PointRef.transform(ref, op)
  }

  for (const ref of Editor.rangeRefs(editor)) {
    RangeRef.transform(ref, op)
  }
dirtyPaths

dirtyPaths 一共有以下两种生成机制:

  1. 一种是在 operation apply 之前的 oldDirtypath
  2. 一种由 getDirthPaths 方法获取
const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
        if (path) {
          const key = path.join(',')

          if (!set.has(key)) {
            set.add(key)
            dirtyPaths.push(path)
          }
        }
      }
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
}

for (const path of newDirtyPaths) {
        add(path)
}
执行变更操作
Transforms.transform(editor, op)

Transforms.transform(editor, op) 就是在调用 GeneralTransforms 处理 Operation。

const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
 switch (op.type) {
  ...
  case 'insert_text': {
      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

      if (selection) {
        for (const [point, key] of Range.points(selection)) {
          selection[key] = Point.transform(point, op)!
        }
      }

      break
    }
   ....
 }
}
记录 operation
editor.operations.push(op)
数据校验
Editor.normalize(editor)
触发变更回调
if (!FLUSHING.get(editor)) {
        // 表示需要清空 operations
        FLUSHING.set(editor, true)

        Promise.resolve().then(() => {
           // 清空完毕
          FLUSHING.set(editor, false)
          // 通知变更回调函数
          editor.onChange()
          // 清空 operations
          editor.operations = []
        })
      }

model 数据校验

对 model 进行变更之后还需要对 model 进行数据校验,避免内容出错。数据校验的机制有两个重点,一是对 dirtyPaths 的管理,一个是 withoutNormalizing 机制。

withoutNormalizing

你可以从 这里 看到源码。

export const Editor: EditorInterface = {
  .....
  withoutNormalizing(editor: Editor, fn: () => void): void {
    const value = Editor.isNormalizing(editor)
    NORMALIZING.set(editor, false)
    try {
      fn()
    } finally {
      NORMALIZING.set(editor, value)
    }
    Editor.normalize(editor)
  }
}
export const NORMALIZING: WeakMap<Editor, boolean> = new WeakMap()

可以看到这段代码通过 WeakMap 保存了是否需要数据校验的状态。

dirtyPaths

dirtyPaths 通过 editor.apply 方法形成

const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
        if (path) {
          const key = path.join(',')

          if (!set.has(key)) {
            set.add(key)
            dirtyPaths.push(path)
          }
        }
      }
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)

for (const path of oldDirtyPaths) {
        const newPath = Path.transform(path, op)
        add(newPath)
}

for (const path of newDirtyPaths) {
        add(path)
}

Editor.normalize(editor) 的 normalize 方法,它创建一个循环,从 model 树的叶节点自底向上地不断获取脏路径并调用 nomalizeNode 检验路径所对应的节点是否合法。

export const Editor: EditorInterface = {
  .....
  
  normalize(
    editor: Editor,
    options: {
      force?: boolean
    } = {}
  ): void {
    ....

    Editor.withoutNormalizing(editor, () => {
      
      .....

      const max = getDirtyPaths(editor).length * 42 // HACK: better way?
      let m = 0

      while (getDirtyPaths(editor).length !== 0) {
        if (m > max) {
          throw new Error(`
            Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
          `)
        }

        const dirtyPath = getDirtyPaths(editor).pop()!

        // If the node doesn't exist in the tree, it does not need to be normalized.
        if (Node.has(editor, dirtyPath)) {
          const entry = Editor.node(editor, dirtyPath)
          editor.normalizeNode(entry)
        }
        m++
      }
    })
  },
  .....
}

简图

未命名绘图 (18).png

Slate.js 插件体系

Slate 的插件只是一个返回 editor 实例的函数,在这个函数中通过重写编辑器实例方法,修改编辑器行为。 在创建编辑器实例的时候调用插件函数即可。

const withImages = editor => {
  const { isVoid } = editor

  editor.isVoid = element => {
    return element.type === 'image' ? true : isVoid(element)
  }

  return editor
}

然后可以这样使用它:

import { createEditor } from 'slate'

const editor = withImages(createEditor())

slate-history

slate-history 踪随着时间推移对 Slate 值状态的更改,并启用撤消和重做功能。

截屏2021-06-05 下午6.12.00.png

withHistory

你可以从 这里 看到源码。

export const withHistory = <T extends Editor>(editor: T) => {
  const e = editor as T & HistoryEditor
  const { apply } = e
  e.history = { undos: [], redos: [] }

  e.redo = () => {
    const { history } = e
    const { redos } = history

    if (redos.length > 0) {
      const batch = redos[redos.length - 1]

      HistoryEditor.withoutSaving(e, () => {
        Editor.withoutNormalizing(e, () => {
          for (const op of batch) {
            e.apply(op)
          }
        })
      })

      history.redos.pop()
      history.undos.push(batch)
    }
  }

  e.undo = () => {
    const { history } = e
    const { undos } = history

    if (undos.length > 0) {
      const batch = undos[undos.length - 1]

      HistoryEditor.withoutSaving(e, () => {
        Editor.withoutNormalizing(e, () => {
          const inverseOps = batch.map(Operation.inverse).reverse()

          for (const op of inverseOps) {
            e.apply(op)
          }
        })
      })

      history.redos.push(batch)
      history.undos.pop()
    }
  }

  e.apply = (op: Operation) => {
    const { operations, history } = e
    const { undos } = history
    const lastBatch = undos[undos.length - 1]
    const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
    const overwrite = shouldOverwrite(op, lastOp)
    let save = HistoryEditor.isSaving(e)
    let merge = HistoryEditor.isMerging(e)

    if (save == null) {
      save = shouldSave(op, lastOp)
    }

    if (save) {
      if (merge == null) {
        if (lastBatch == null) {
          merge = false
        } else if (operations.length !== 0) {
          merge = true
        } else {
          merge = shouldMerge(op, lastOp) || overwrite
        }
      }

      if (lastBatch && merge) {
        if (overwrite) {
          lastBatch.pop()
        }

        lastBatch.push(op)
      } else {
        const batch = [op]
        undos.push(batch)
      }

      while (undos.length > 100) {
        undos.shift()
      }

      if (shouldClear(op)) {
        history.redos = []
      }
    }

    apply(op)
  }

  return e
}

withHistory 方法中,slate-history 在 editor 上创建了两个数组用来存储历史操作:

e.history = { undos: [], redos: [] }

它们的类型都是 Operation[][],即 Operation 的二维数组,其中的每一项代表了一批操作(在代码上称作 batch), batch 可含有多个 Operation。

slate-history 通过覆写 apply 方法来在 Operation 的 apply 流程之前插入 undo/redo 的相关逻辑,最后调用原来的 apply 方法。

 e.apply = (op: Operation) => {
    const { operations, history } = e
    const { undos } = history
    const lastBatch = undos[undos.length - 1]
    const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
    const overwrite = shouldOverwrite(op, lastOp)
    let save = HistoryEditor.isSaving(e)
    let merge = HistoryEditor.isMerging(e)

    if (save == null) {
      save = shouldSave(op, lastOp)
    }

    if (save) {
      if (merge == null) {
        if (lastBatch == null) {
          merge = false
        } else if (operations.length !== 0) {
          merge = true
        } else {
          merge = shouldMerge(op, lastOp) || overwrite
        }
      }

      if (lastBatch && merge) {
        if (overwrite) {
          lastBatch.pop()
        }

        lastBatch.push(op)
      } else {
        const batch = [op]
        undos.push(batch)
      }

      while (undos.length > 100) {
        undos.shift()
      }

      if (shouldClear(op)) {
        history.redos = []
      }
    }

    apply(op)
  }

undo 方法

e.undo = () => {
  const { history } = e
  const { undos } = history

  if (undos.length > 0) {
    const batch = undos[undos.length - 1]

    HistoryEditor.withoutSaving(e, () => {
      Editor.withoutNormalizing(e, () => {
        const inverseOps = batch.map(Operation.inverse).reverse()

        for (const op of inverseOps) {
          // If the final operation is deselecting the editor, skip it. This is
          if (
            op === inverseOps[inverseOps.length - 1] &&
            op.type === 'set_selection' &&
            op.newProperties == null
          ) {
            continue
          } else {
            e.apply(op)
          }
        }
      })
    })

    history.redos.push(batch)
    history.undos.pop()
  }
}

redo 方法

e.redo = () => {
    const { history } = e
    const { redos } = history

    if (redos.length > 0) {
      const batch = redos[redos.length - 1]

      HistoryEditor.withoutSaving(e, () => {
        Editor.withoutNormalizing(e, () => {
          for (const op of batch) {
            e.apply(op)
          }
        })
      })

      history.redos.pop()
      history.undos.push(batch)
    }
  }

slate-react

slate-react 编辑器的 React 组件,渲染文档数据。

渲染原理

Slate 的文档数据是一颗类似 DOM 的节点树结构,slate-react 通过递归这颗树生成 children 数组 , 最终 react 将 children 数组中的组件渲染到页面上。

  • 设置编辑器实例的 children 属性
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/slate.tsx#L17

export const Slate = (props: {
  editor: ReactEditor
  value: Descendant[]
  children: React.ReactNode
  onChange: (value: Descendant[]) => void
}) => {
  ....
  const context: [ReactEditor] = useMemo(() => {
    // 设置 editor 实例的 children 属性为 value
    editor.children = value
    .....
  }, [key, value, ...Object.values(rest)])
  
  .....
}
  • Editable 组件传递 editor 实例给 useChildren Hooks 组件。
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/editable.tsx#L100

export const Editable = (props: EditableProps) => {
 const editor = useSlate()
 ....
 return (
  <ReadOnlyContext.Provider value={readOnly}>
   <DecorateContext.Provider value={decorate}>
    <Component
    ....
    >
    {useChildren({
            decorations,
            node: editor,
            renderElement,
            renderPlaceholder,
            renderLeaf,
            selection: editor.selection,
          })}
     </Component>
    </DecorateContext.Provider>
   </ReadOnlyContext.Provider>
 )
}
  • useChildren 生成渲染数组,交给 React 渲染组件。 useChildren 组件会根据 children 中各个 Node 的类型,生成对应的 ElementComponent 或者 TextComponent
const useChildren = (props: {
  decorations: Range[]
  node: Ancestor
  renderElement?: (props: RenderElementProps) => JSX.Element
  renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
  renderLeaf?: (props: RenderLeafProps) => JSX.Element
  selection: Range | null
}) => {
  const decorate = useDecorate()
  const editor = useSlateStatic()
  const path = ReactEditor.findPath(editor, node)
  const children = []
  const isLeafBlock =
    Element.isElement(node) &&
    !editor.isInline(node) &&
    Editor.hasInlines(editor, node)

  for (let i = 0; i < node.children.length; i++) {
    const p = path.concat(i)
    const n = node.children[i] as Descendant
    const key = ReactEditor.findKey(editor, n)
    const range = Editor.range(editor, p)
    const sel = selection && Range.intersection(range, selection)
    const ds = decorate([n, p])

    for (const dec of decorations) {
      const d = Range.intersection(dec, range)

      if (d) {
        ds.push(d)
      }
    }

    if (Element.isElement(n)) {
      children.push(
        <ElementComponent
          decorations={ds}
          element={n}
          key={key.id}
          renderElement={renderElement}
          renderPlaceholder={renderPlaceholder}
          renderLeaf={renderLeaf}
          selection={sel}
        />
      )
    } else {
      children.push(
        <TextComponent
          decorations={ds}
          key={key.id}
          isLast={isLeafBlock && i === node.children.length - 1}
          parent={node}
          renderPlaceholder={renderPlaceholder}
          renderLeaf={renderLeaf}
          text={n}
        />
      )
    }

    NODE_TO_INDEX.set(n, i)
    NODE_TO_PARENT.set(n, node)
  }

  return children
}

未命名绘图 (19).png

官网例子

截屏2021-06-05 下午7.58.40.png

自定义渲染

传递渲染函数 renderElementrenderLeaf 给 Editable 组件,用户可以通过提供这两个参数来自行决定如何渲染 model 中的一个 Node。我们以官网 richtext demo 为例。

const RichTextExample = () => {
  ...
  const renderElement = useCallback(props => <Element {...props} />, [])
  const renderLeaf = useCallback(props => <Leaf {...props} />, [])
  
    return (
    <Slate editor={editor} value={value} onChange={value => setValue(value)}>
       ....
      <Editable
        renderElement={renderElement}
        renderLeaf={renderLeaf}
        .....
      />
    </Slate>
  )
}
 const Element = ({ attributes, children, element }) => {
  switch (element.type) {
    case 'block-quote':
      return <blockquote {...attributes}>{children}</blockquote>
    case 'bulleted-list':
      return <ul {...attributes}>{children}</ul>
    case 'heading-one':
      return <h1 {...attributes}>{children}</h1>
    case 'heading-two':
      return <h2 {...attributes}>{children}</h2>
    case 'list-item':
      return <li {...attributes}>{children}</li>
    case 'numbered-list':
      return <ol {...attributes}>{children}</ol>
    default:
      return <p {...attributes}>{children}</p>
  }
}

这个 demo 就拓展了 Element 节点的 type 属性,让 Element 能够渲染为不同的标签。

slate-hyperscript

slate-hyperscript 使用 JSX 编写 Slate 文档的 hyperscript 工具。

总结

  1. Slate 目前处于测试状态,它的一些 APIs 还没有 "最终确定";
  2. 使用了 contenteditable 导致无法处理部分选区和输入事件;

参考资料

Slate 中文文档

slate 架构设计分析