Skip to content

Latest commit

 

History

History
431 lines (328 loc) · 15.6 KB

README.zh-CN.md

File metadata and controls

431 lines (328 loc) · 15.6 KB

Editable

Editable 是一个可扩展的富文本编辑器框架,专注于稳定性、可控性和性能。为此,我们没有使用原生的可编辑属性contenteditable,而是使用了一个自定义的渲染器,这使得我们可以更好地控制编辑器的行为。从此,您不必再担心跨平台和浏览器兼容性问题(例如SelectionInput),只需专注于您的业务逻辑。

预览

preview

您可以在此处查看演示:https://docs.editablejs.com/playground


  • 为什么没有使用 canvas 渲染?

    虽然canvas渲染的性能可能比dom渲染更快,但是canvas的开发体验不佳,需要编写更多代码。

  • 为什么使用React渲染?

    React 使插件变得更加灵活,且生态很好。但是,React 的性能不如原生DOM。

    在富文本中我理想中的前端框架应该是这样的:

    1. 没有虚拟DOM
    2. 没有diff算法
    3. 没有proxy对象

    因此,我比较了VueSolid-jsSvelteJS等前端框架,发现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的数据模型,并对它进行了一定的扩展(增加了GridList相关数据结构和操作),依赖这些成熟优秀的数据结构可以使我们的编辑器更加稳定。

我们把Slate的所有api都封装到了@editablejs/models中,因此Slateapi你都可以从@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: '请输入标题'
})

Yjs 插件

@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 提供了一个序列化器,它可以将编辑器的数据序列化为 htmltextmarkdown 格式。

当前提供的插件都已经实现了序列化的转换器,你可以直接使用。

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 提供了一个反序列化器,它可以将 htmltextmarkdown 格式的数据反序列化为编辑器的数据。

当前提供的插件都已经实现了反序列化的转换器,你可以直接使用。

使用方法与序列化类似,只是导入包的路径需要从 @editablejs/serializer 改为 @editablejs/deserializer

贡献 ✨

欢迎 🌟 Stars 和 📥 PR!,共同构建更好的富文本编辑器!

贡献指南 在这里,欢迎阅读。如果你有不错的插件,欢迎分享给我们。

特别感谢 Sparticle 的支持,为开源事业做出了贡献。

sparticle

最后,感谢所有为这个项目做出贡献的人!(emoji key):

Kevin Lin
Kevin Lin

💻
kailunyao
kailunyao

💻
ren.chen
ren.chen

📖
han
han

📖

该项目遵循 all-contributors 规范,欢迎任何形式的贡献!

感谢

我们还要感谢这些提供帮助的开源项目:

  • Slate - 提供数据模型的支持。
  • Yjs - CRDTs 的基本支持,用于我们在协作编辑的支持。
  • React - 视图层支持。
  • Zustand - 前端的最小状态管理工具。
  • 其他依赖项

我们使用以下开源项目来帮助我们构建更好的开发体验:

  • Turborepo -- pnpm + turbo 是很棒的 monorepo 管理器和构建系统

License

查看 LICENSE 详情。