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

吐槽君 分类:javascript

preview.png

图片来源:github.com/ianstormtay…

Slate 简介

Slate 是一个使用 TypeScript 开发的富文本编辑器开发框架,诞生于 2016 年,作者是 Ian Storm Taylor。Slate 是一个完全可定制的富文本编辑器框架。Slate 让你构建像 Medium, Dropbox Paper 或者是 Google Docs 这样丰富,直观的编辑器。你可以认为它是基于 React 的一种可拔插的 contenteditable 实现。它的灵感来源于 Draft.js,Prosemirror 和 Quill 这样的库。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 架构设计分析

网站出售中,有意者加微信:javadudu

回复

我来回复
  • 暂无回复内容