富文本editor

720 阅读6分钟

1、50行代码撸一个简易编辑器

现有的富文本编辑器,底层是基于 contenteditable+document.execCommand,使用API可参考mdn文档:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

下面基于上面两个api,实现可以设置文本格式,插入html和图片。
contenteditable 编辑器自带的粘贴是带格式的,我们可以实现word上的仅粘贴文档功能。

image.png

代码如下:

<html>
<head>
    <style>
        .editor {
            border: 1px solid #999;
            padding: 10px;
            width: 600px;
            min-height: 300px;
        }
    </style>
</head>
<body>
    <div>
        <button onclick="collapseToEnd(false)">selectall</button>
        <button onclick="collapseToEnd(true)">collapseToEnd</button>
        <input type="checkbox" id="formatpaste" checked>格式化粘贴</input>
    </div>
    <br />
    <div>
        <button data-command="formatBlock" data-value="h1" data-toend="true"
            onclick="changeStyle(this.dataset)">h1</button>
        <button data-command="italic" onclick="changeStyle(this.dataset)">斜体</button>
        <button data-command="fontSize" data-value="4" onclick="changeStyle(this.dataset)">4号</button>
    </div>
    <br />
    <div>
        <button onclick="insertHtml()">insertHtml</button>
        <button>insertImage:<input type="file" id="chooseImage" onchange="insertImage()" accept="image/png,image/jpg,image/jpeg" num="1"></button>
    </div>
    <br />
    <div class="editor" contenteditable></div>

    <script>
        const editorDom = document.querySelector('.editor')
        function collapseToEnd(ToEnd) {
            var range = window.getSelection()
            range.selectAllChildren(editorDom)
            ToEnd && range.collapseToEnd()
        }

        function changeStyle(data) {
            collapseToEnd(data.toend)
            document.execCommand(data.command, false, data.value)
        }

        function insertHtml() {
            collapseToEnd(true)
            const html = prompt('请输入html代码', '<span style="color:red;">你好</span>')
            document.execCommand('insertHtml', false, html)
        }

        function insertImage(url){
            collapseToEnd(true)
            const file = document.getElementById('chooseImage').files[0];
            const objUrl = window.URL.createObjectURL(file)
            //document.execCommand('insertImage', false, objUrl) //无法更改尺寸
            document.execCommand('insertHTML', false, '<img src="' + objUrl + '" width=50 height=50>');
        }

        document.addEventListener('paste', (e) => {
            var checked = document.getElementById("formatpaste").checked;
            if (checked) return
            e.stopPropagation()
            e.preventDefault()
            let text = e.clipboardData.getData('text/plain')
            document.execCommand('insertText', false, text)
        })
        document.addEventListener('readystatechange', () => {
            collapseToEnd(true)
        })
    </script>
</body>

</html>

2、常用的富文档编辑器

3、wangeditor、slate源码解析

3.1 wangeditor

基础用法:

<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
<script>
const { createEditor, createToolbar } = window.wangEditor

const editorConfig = {
    placeholder: 'Type here...',
    onChange(editor) {
      const html = editor.getHtml()
      console.log('editor content', html)
      // 也可以同步到 <textarea>
    }
}

const editor = createEditor({
    selector: '#editor-container',
    html: '<p><br></p>',
    config: editorConfig,
    mode: 'default', // or 'simple'
})

const toolbarConfig = {}

const toolbar = createToolbar({
    editor,
    selector: '#toolbar-container',
    config: toolbarConfig,
    mode: 'default', // or 'simple'
})
</script>

下载源码,在 docs/dev.md 可看到准备工作: 了解 slate.js、了解 vdom 和 snabbdom.js、了解 lerna。这里用到了mvvm的思想,推荐了解一下

  • model层:slate管理了一个 immer不可变类型的 nodes元素、selection选区 slate editor
  • view层:wangeditor利用 slate editor的数据来渲染视图

这里我用另一篇文章【wangeditor源码分析】解读,主要分析 createEditor, createToolbar创建编辑器以及工具栏的过程,接着介绍 注册模块 registerModule的逻辑,为后面自定义扩展新功能打下基础。

3.2 lerna

将大型代码仓库分割成多个独立版本化的 软件包(package)

  • lerna init创建的代码结构如下
  • lerna create 「package1」创建一个 package1 到项目工程的 packages 下
  • lerna add package2 --scope package1 将package2添加到package1的依赖里
  • lerna bootstrap命令安装所有 依赖项并链接任何交叉依赖
lerna-repo/
  packages/
  package.json
  lerna.json

3.3 snabbdom.js

提供函数 h 来创建 vnodes、调用 init 函数返回的 patch 函数将vnodes渲染到页面。 了解过vue/react 底层的同学应该不陌生, patch将新旧虚拟dom比对,然后将更改更新到页面。

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
  toVNode
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule // attaches event listeners
]);

const newVNode = h("div", { style: { color: "#000" } }, [
  h("h1", "Headline"),
  h("p", "A paragraph")
]);

patch(toVNode(document.querySelector(".container")), newVNode);

3.4 slate-react

基础用法

import React, { useState } from 'react'
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'

const initialValue = [
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
]
const App = () => {
  const [editor] = useState(() => withReact(createEditor()))
  return (
    <Slate editor={editor} initialValue={initialValue}>
      <Editable />
    </Slate>
  )
}
  1. 官方的用法是基于react,通过前面的分析我们知道createEditor() 是创建了一个editor对象
  2. withReact方法是对 editor对象的属性进行扩展
  3. slate-react组件 Slate, Editable和wangEditor类似,都是将editor.children对应的vNode挂载在相应的dom节点上,我们大概看下这块的源码
  • model层:slate管理了一个 immer不可变类型的 nodes元素、selection选区 slate editor
  • view层: slate-react利用 slate editor的数据来渲染视图

源码分析

该项目也是通过lerna进行多项目的管理
image.png

  • packages/slate-react/src/components/slate.tsx

Slate 组件主要是对Provider数据的封装

export const Slate = (props: {
  editor: ReactEditor
  initialValue: Descendant[]
  children: React.ReactNode
  onChange?: (value: Descendant[]) => void
  onSelectionChange?: (selection: Selection) => void
  onValueChange?: (value: Descendant[]) => void
}) => {
  const {
    editor,
    children,
    onChange,
    onSelectionChange,
    onValueChange,
    initialValue,
    ...rest
  } = props

  const [context, setContext] = React.useState<SlateContextValue>(() => {
    editor.children = initialValue
    Object.assign(editor, rest)
    return { v: 0, editor }
  })

  useEffect(() => {
    EDITOR_TO_ON_CHANGE.set(editor, onContextChange)

    return () => {
      EDITOR_TO_ON_CHANGE.set(editor, () => {})
    }
  }, [editor, onContextChange])
  ...

  return (
    <SlateSelectorContext.Provider value={selectorContext}>
      <SlateContext.Provider value={context}>
        <EditorContext.Provider value={context.editor}>
          <FocusedContext.Provider value={isFocused}>
            {children}
          </FocusedContext.Provider>
        </EditorContext.Provider>
      </SlateContext.Provider>
    </SlateSelectorContext.Provider>
  )
}
  • packages/slate-react/src/components/editable.tsx

Editable 组件主要是一个带 contentEditable 属性的节点,监听了很多事件,如复制粘贴功能,它可以拦截默认的带格式粘贴,支持自定义粘贴格式,以及拖拽、点击等事件。事件变化,比如粘贴、聚焦会相应同步 slate editor的数据。

我们进一步查看 Children组件的useChildren方法,我们可以推测,useChildren方法就是生成 editor对象对应的vnode

const Children = (props: Parameters<typeof useChildren>[0]) => (
  <React.Fragment>{useChildren(props)}</React.Fragment>
)

export const Editable = (props: EditableProps) => {
  ...
  EDITOR_TO_FORCE_RENDER.set(editor, forceRender)
  ...

  return (
    <ReadOnlyContext.Provider value={readOnly}>
      <DecorateContext.Provider value={decorate}>
        <RestoreDOM node={ref} receivedUserInput={receivedUserInput}>
          <Component
            role={readOnly ? undefined : 'textbox'}
            aria-multiline={readOnly ? undefined : true}
            {...attributes}
            data-slate-editor
            data-slate-node="value"
            // explicitly set this 明确设置contentEditable属性
            contentEditable={!readOnly}
            onBeforeInput={...}
            onInput={...}
            onBlur={...}
            onClick={...}
            onCopy={useCallback(
              (event) => {
                if (...) {
                  event.preventDefault()
                  ReactEditor.setFragmentData(
                    editor,
                    event.clipboardData,
                    'copy'
                  )
                }
              },
              [attributes.onCopy, editor]
            )}
            onDragStart={...}
            onDragEnd={...}
            onPaste={useCallback(
              (event) => {
                 if (...) {
                    event.preventDefault()
                    ReactEditor.insertData(editor, event.clipboardData)
                  }
                }
              },
              [readOnly, editor, attributes.onPaste]
            )}
          >
            <Children
              decorations={decorations}
              node={editor}
              renderElement={renderElement}
              renderPlaceholder={renderPlaceholder}
              renderLeaf={renderLeaf}
              selection={editor.selection}
            />
          </Component>
        </RestoreDOM>
      </DecorateContext.Provider>
    </ReadOnlyContext.Provider>
  )
}
  • packages/slate-react/src/hooks/use-children.tsx

这块的写法和 wangeditor->updateView->node2Vnode逻辑异曲同工。

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 {
    decorations,
    node,
    renderElement,
    renderPlaceholder,
    renderLeaf,
    selection,
  } = props
  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(
        <SelectedContext.Provider key={`provider-${key.id}`} value={!!sel}>
          <ElementComponent
            decorations={ds}
            element={n}
            key={key.id}
            renderElement={renderElement}
            renderPlaceholder={renderPlaceholder}
            renderLeaf={renderLeaf}
            selection={sel}
          />
        </SelectedContext.Provider>
      )
    } 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
}

3.5 slate

上面分析 wangeditorslate-react源码我们可以看出两者功能类似,都属于视图层,都是将 slate->createEditor()生成的editor 模型数据 转化为vnode,然后挂载在带有 contenteditable 属性的节点上。下面我们发掘一下 slate 框架的魅力吧

这部分也另外写一篇文章【slate源码解读

4、写在最后!!!

富文本编辑器 系列文章:

  1. 富文本editor
  2. wangeditor源码分析
  3. slate源码解读

通过对编辑器源码的解读,我学会了很多新思想,下面总结一下

  • model层:slate管理了一个 immer不可变类型的 nodes元素、selection选区 slate editor
  • view层:wangeditorslate-react利用 slate editor的数据来渲染视图
  1. 文本标签 input 和 textarea它们都不能设置丰富的样式,于是我们采用 contenteditable 属性的编辑框,常规做法是结合 document.execCommand 命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditorslate-react采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过mvvm的思想更改页面的元素。

  2. MV:分析 wangeditorslate-react源码我们可以看出两者功能类似,都是将 slate editor 转化为vnode,然后将vnode挂载在带有 contenteditable 属性的节点上;slate-react是基于react,wangeditor是通过snabbdom.js,做到了与框架无关

  3. VM:菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域slate editor的内容,它主要思路就是 通过 editor的api 对其nodes元素、selection选区更改

欢迎关注我的前端自检清单,我和你一起成长