Editable 是一个可扩展的富文本编辑器框架,专注于稳定性、可控性和性能。为此,我们没有使用原生的可编辑属性contenteditable,而是使用了一个自定义的渲染器,这使得我们可以更好地控制编辑器的行为。从此,您不必再担心跨平台和浏览器兼容性问题(例如Selection
、Input
),只需专注于您的业务逻辑。
您可以在此处查看演示:https://docs.editablejs.com/playground
-
为什么没有使用
canvas
渲染?虽然
canvas
渲染的性能可能比dom渲染更快,但是canvas的开发体验不佳,需要编写更多代码。 -
为什么使用React渲染?
React
使插件变得更加灵活,且生态很好。但是,React
的性能不如原生DOM。在富文本中我理想中的前端框架应该是这样的:
- 没有虚拟DOM
- 没有diff算法
- 没有proxy对象
因此,我比较了
Vue
、Solid-js
、SvelteJS
等前端框架,发现Solid-js
符合前两个条件,但每个属性都会被proxy
包装,这在做扩展时可能会与纯JS对象做===
比对时出现问题。为了提高性能,我们很可能会在后续的开发中将其重构为原生DOM渲染。
目前,React满足以下两个标准:
- 开发体验
- 插件扩展性
- 跨前端兼容性
- 渲染性能
在后续的重构选择中,我们会尽量平衡以上这四个标准。
当前版本你仍然需要与
React
一起使用,但是我们会在后续版本中将其重构为原生DOM渲染。
安装 @editablejs/models
和 @editablejs/editor
依赖:
npm i --save @editablejs/models @editablejs/editor
下面是一个最小可以编辑文本编辑器:
import * as React from 'react'
import { createEditor } from '@editablejs/models'
import { EditableProvider, ContentEditable, withEditable } from '@editablejs/editor'
const App = () => {
const editor = React.useMemo(() => withEditable(createEditor()), [])
return (
<EditableProvider editor={editor}>
<ContentEditable placeholder="Please enter content..." />
</EditableProvider>)
}
@editablejs/models
提供了一个数据模型,用于描述编辑器的状态,以及对编辑器状态的操作。
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Hello World'
}
]
}
可以看到他的结构与Slate
非常相似,我们没有新建一个数据模型,而是直接使用了Slate
的数据模型,并对它进行了一定的扩展(增加了Grid
、List
相关数据结构和操作),依赖这些成熟优秀的数据结构可以使我们的编辑器更加稳定。
我们把Slate
的所有api
都封装到了@editablejs/models
中,因此Slate
的api
你都可以从@editablejs/models
中找到。
如果你对Slate
不太熟悉,可以查看它的文档:https://docs.slatejs.org/
当前我们提供了一些开箱即用的插件,除了实现了基本的功能外,还实现了键盘快捷键
、markdown短语
、markdown序列化
、markdown反序列
、HTML序列化
、HTML反序列化
。
@editablejs/plugin-context-menu
提供了一个上下文菜单,由于我们没有使用原生菜单的部分功能不再起作用,所以我们需要自己定义上下文菜单的功能。contenteditble
@editablejs/plugin-align
对齐@editablejs/plugin-blockquote
引用块@editablejs/plugin-codeblock
代码块@editablejs/plugin-font
包含了前景色、背景色、字体大小@editablejs/plugin-heading
标题@editablejs/plugin-hr
分割线@editablejs/plugin-image
图片@editablejs/plugin-indent
缩进@editablejs/plugin-leading
行距@editablejs/plugin-link
链接@editablejs/plugin-list
包含了有序列表、无序列表、任务列表@editablejs/plugin-mark
包含了加粗、斜体、删除线、下划线、上标、下标、行内代码@editablejs/plugin-mention
提及@editablejs/plugin-table
表格
单个插件的使用方法,以 plugin-mark
为例:
import { withMark } from '@editablejs/mark'
const editor = React.useMemo(() => {
const editor = withEditable(createEditor())
return withMark(editor)
}, [])
上面的常用插件您还可以通过@editablejs/plugins
中的withPlugins
方法来快速使用:
import { withPlugins } from '@editablejs/plugins'
const editor = React.useMemo(() => {
const editor = withEditable(createEditor())
return withPlugins(editor)
}, [])
@editablejs/plugin-history
插件提供了撤销、重做功能。
import { withHistory } from '@editablejs/plugin-history'
const editor = React.useMemo(() => {
const editor = withEditable(createEditor())
return withHistory(editor)
}, [])
一般我们在开发文档、博客的应用时,我们会有一个标题与主体内容是分开的,它通常会在编辑器外使用 input
或者 textarea
来实现。如果在协作环境中,由于是独立于编辑器外部的,要实现标题的实时同步,就需要做额外的工作。
@editablejs/plugin-title
插件就是为了解决这个问题的,它将编辑器的第一个子节点作为标题,融于编辑器整个数据结构中,这样它就可以拥有编辑器所拥有的特性了。
import { withTitle } from '@editablejs/plugin-title'
const editor = React.useMemo(() => {
const editor = withEditable(createEditor())
return withTitle(editor)
}, [])
它也有一个独立的 placeholder
属性,用于设置标题的占位符。
return withTitle(editor, {
placeholder: '请输入标题'
})
@editablejs/plugin-yjs
插件提供了对Yjs
的支持,它可以将编辑器的数据实时同步到其他客户端。
你需要安装下面的依赖:
-
yjs
yjs 的核心库 -
@editablejs/yjs-websocket
yjs 的 websocket 通信库另外它还提供了
nodejs
服务端的实现,你可以使用它来搭建一个yjs
的服务:import startServer from '@editablejs/yjs-websocket/server' startServer()
-
@editablejs/plugin-yjs
与编辑器一起使用的yjs
插件
npm i yjs @editablejs/yjs-websocket @editablejs/plugin-yjs
使用方法:
import * as Y from 'yjs'
import { withYHistory, withYjs, YjsEditor, withYCursors, CursorData, useRemoteStates } from '@editablejs/plugin-yjs'
import { WebsocketProvider } from '@editablejs/yjs-websocket'
// 创建一个yjs的文档
const document = React.useMemo(() => new Y.Doc(), [])
// 创建一个 websocket 的 provider
const provider = React.useMemo(() => {
return typeof window === 'undefined'
? null
: new WebsocketProvider(yjs服务端地址, 'editable', document, {
connect: false,
})
}, [document])
// 创建一个编辑器
const editor = React.useMemo(() => {
// 获取yjs文档中的content字段,它是一个XmlText类型
const sharedType = document.get('content', Y.XmlText) as Y.XmlText
let editor = withYjs(withEditable(createEditor()), sharedType, { autoConnect: false })
if (provider) {
// 与其他客户端的光标同步
editor = withYCursors(editor, provider.awareness, {
data: {
name: '张三',
color: '#f00',
},
})
}
// 历史记录
editor = withHistory(editor)
// yjs 的历史记录
editor = withYHistory(editor)
}, [provider])
// 连接到yjs服务端
React.useEffect(() => {
provider?.connect()
return () => {
provider?.disconnect()
}
}, [provider])
创建一个自定义插件非常简单,我们只需要拦截 renderElement
方法,然后判断当前节点是否是我们需要的节点,如果是,就渲染我们自定义的组件。
一个自定义插件的例子:
import { Editable } from '@editablejs/editor'
import { Element, Editor } from '@editablejs/models'
// 定义一个插件的类型
export interface MyPlugin extends Element {
type: 'my-plugin'
// ... 您还可以自定义一些其它属性
}
export const MyPlugin = {
// 判断一个节点是否是 MyPlugin 的插件
isMyPlugin(editor: Editor, element: Element): element is MyPlugin {
return Element.isElement(value) && element.type === 'my-plugin'
}
}
export const withMyPlugin = <T extends Editable>(editor: T) => {
const { isVoid, renderElement } = editor
// 拦截 isVoid 方法,如果是 MyPlugin 的节点,就返回 true
// 除了 isVoid 方法,还有 `isBlock` `isInline` 等方法,可以按需要拦截
editor.isVoid = element => {
return MyPlugin.isMyPlugin(editor, element) || isVoid(element)
}
// 拦截 renderElement 方法,如果是 MyPlugin 的节点,就渲染自定义的组件
// attributes 是节点的属性,我们需要将它传递给自定义组件
// children 是节点的子节点,里面包含了节点的子节点,我们必须要渲染它
// element 是当前节点,你可以在里面找到你自定义的属性
editor.renderElement = ({ attributes, children, element }) => {
if (MyPlugin.isMyPlugin(editor, element)) {
return <div {...attributes}>
<div>My Plugin</div>
{children}
</div>
}
return renderElement({ attributes, children, element })
}
return editor
}
@editablejs/serializer
提供了一个序列化器,它可以将编辑器的数据序列化为 html
,text
,markdown
格式。
当前提供的插件都已经实现了序列化的转换器,你可以直接使用。
HTML 序列化
// html 序列化器
import { HTMLSerializer } from '@editablejs/serializer/html'
// 导入 plugin-mark 插件的HTML序列化转换器,其它插件同理
import { withMarkHTMLSerializerTransform } from '@editablejs/plugin-mark/serializer/html'
// 使用转换器
HTMLSerializer.withEditor(editor, withMarkHTMLSerializerTransform, {})
// 序列化成 HTML
const html = HTMLSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello', bold: true }] })
// output: <p><strong>hello</strong></p>
Text 序列化
// text 序列化器
import { TextSerializer } from '@editablejs/serializer/text'
// 导入 plugin-mention 插件的 Text 序列化转换器
import { withMentionTextSerializerTransform } from '@editablejs/plugin-mention/serializer/text'
// 使用转换器
TextSerializer.withEditor(editor, withMentionTextSerializerTransform, {})
// 序列化成 Text
const text = TextSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello' }, {
type: 'mention',
children: [{ text: '' }],
user: {
name: '张三',
id: '1',
},
}] })
// output: hello @张三
Markdown 序列化
// markdown 序列化器
import { MarkdownSerializer } from '@editablejs/serializer/markdown'
// 导入 plugin-mark 插件的 Markdown 序列化转换器
import { withMarkMarkdownSerializerTransform } from '@editablejs/plugin-mark/serializer/markdown'
// 使用转换器
MarkdownSerializer.withEditor(editor, withMarkMarkdownSerializerTransform, {})
// 序列化成 Markdown
const markdown = MarkdownSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello', bold: true }] })
// output: **hello**
每个插件都需要去导入它的序列化转换器,这样太繁琐了,所以我们在 @editablejs/plugins
中提供了它内置所有插件的序列化转换器。
import { withHTMLSerializerTransform } from '@editablejs/plugins/serializer/html'
import { withTextSerializerTransform } from '@editablejs/plugins/serializer/text'
import { withMarkdownSerializerTransform, withMarkdownSerializerPlugin } from '@editablejs/plugins/serializer/markdown'
useLayoutEffect(() => {
withMarkdownSerializerPlugin(editor)
withTextSerializerTransform(editor)
withHTMLSerializerTransform(editor)
withMarkdownSerializerTransform(editor)
}, [editor])
@editablejs/serializer
提供了一个反序列化器,它可以将 html
,text
,markdown
格式的数据反序列化为编辑器的数据。
当前提供的插件都已经实现了反序列化的转换器,你可以直接使用。
使用方法与序列化类似,只是导入包的路径需要从 @editablejs/serializer
改为 @editablejs/deserializer
。
欢迎 🌟 Stars 和 📥 PR!,共同构建更好的富文本编辑器!
贡献指南 在这里,欢迎阅读。如果你有不错的插件,欢迎分享给我们。
特别感谢 Sparticle 的支持,为开源事业做出了贡献。
最后,感谢所有为这个项目做出贡献的人!(emoji key):
Kevin Lin 💻 |
kailunyao 💻 |
ren.chen 📖 |
han 📖 |
该项目遵循 all-contributors 规范,欢迎任何形式的贡献!
我们还要感谢这些提供帮助的开源项目:
我们使用以下开源项目来帮助我们构建更好的开发体验:
- Turborepo -- pnpm + turbo 是很棒的 monorepo 管理器和构建系统
查看 LICENSE 详情。