1、50行代码撸一个简易编辑器
现有的富文本编辑器,底层是基于 contenteditable
+document.execCommand
,使用API可参考mdn文档:
-
document.execCommand 当一个 HTML 文档切换到设计模式时,
document
暴露execCommand
方法,该方法允许运行命令来操纵可编辑内容区域的元素。
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
下面基于上面两个api,实现可以设置文本格式,插入html和图片。
contenteditable 编辑器自带的粘贴是带格式的,我们可以实现word上的仅粘贴文档功能。
代码如下:
<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、常用的富文档编辑器
- Draft.js star:22.5k 最新更新两年前
- quillstar:38.5k
- Slate.jsstar:28.7k
- tinymcestar:14.1k
- wangeditorstar:16.8k
- ueditor star:6.6k 百度编辑器,多年没更新
- prosemirror
- tiptapstar:23.1k
- ckeditorstar:8k 15年历史
- ContentTools star:3.9k 最新更新两年前
- jodit star:1.5k
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>
)
}
- 官方的用法是基于react,通过前面的分析我们知道
createEditor()
是创建了一个editor对象 withReact
方法是对 editor对象的属性进行扩展slate-react
组件Slate, Editable
和wangEditor类似,都是将editor.children对应的vNode挂载在相应的dom节点上,我们大概看下这块的源码
- model层:
slate
管理了一个 immer不可变类型的 nodes元素、selection选区slate editor
- view层:
slate-react
利用slate editor
的数据来渲染视图
源码分析
该项目也是通过lerna进行多项目的管理
- 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
上面分析 wangeditor
和 slate-react
源码我们可以看出两者功能类似,都属于视图层,都是将 slate->createEditor()
生成的editor 模型数据 转化为vnode,然后挂载在带有 contenteditable
属性的节点上。下面我们发掘一下 slate
框架的魅力吧
这部分也另外写一篇文章【slate源码解读】
4、写在最后!!!
富文本编辑器 系列文章:
通过对编辑器源码的解读,我学会了很多新思想,下面总结一下
- model层:
slate
管理了一个 immer不可变类型的 nodes元素、selection选区slate editor
- view层:
wangeditor
和slate-react
利用slate editor
的数据来渲染视图
-
文本标签
input 和 textarea
它们都不能设置丰富的样式,于是我们采用contenteditable
属性的编辑框,常规做法是结合document.execCommand
命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditor
和slate-react
采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过mvvm的思想更改页面的元素。 -
MV:分析
wangeditor
和slate-react
源码我们可以看出两者功能类似,都是将slate editor
转化为vnode,然后将vnode挂载在带有contenteditable
属性的节点上;slate-react
是基于react,wangeditor
是通过snabbdom.js
,做到了与框架无关 -
VM:菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域
slate editor
的内容,它主要思路就是 通过editor的api
对其nodes元素、selection选区更改
欢迎关注我的前端自检清单,我和你一起成长