Editable

Editable is an extensible rich text editor framework that uses custom rende...

README

Editable


Editable is an extensible rich text editor framework that focuses on stability, controllability, and performance. To achieve this, we did not use the native editable attribute [contenteditable](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable), but instead used a custom renderer that allows us to better control the editor's behavior. From now on, you no longer have to worry about cross-platform and browser compatibility issues (such as Selection, Input), just focus on your business logic.

You can see a demo here: https://docs.editablejs.com/playground


- Why not use canvas rendering?

  Although canvas rendering may be faster than DOM rendering in terms of performance, the development experience of canvas is not good and requires writing more code.

- Why use React for rendering?

  React makes plugins more flexible and has a good ecosystem. However, React's performance is not as good as native DOM.

  In my ideal frontend framework for rich text, it should be like this:

  1. No virtual DOM
  2. No diff algorithm
  3. No proxy object

  Therefore, I compared frontend frameworks such as Vue, Solid-js, and SvelteJS and found that Solid-js meets the first two criteria, but each property is wrapped in a proxy, which may cause problems when comparing with pure JS objects using === during extension development.

  To improve performance, we are likely to refactor it for native DOM rendering in future development.

Currently, React meets the following two standards:

- [x] Development experience
- [x] Plugin extensibility
- [ ] Cross-frontend compatibility
- [ ] Rendering performance

In the subsequent refactoring selection, we will try to balance these four standards as much as possible.

Quick Start


Currently, you still need to use it with React for the current version, but we will refactor it for native DOM rendering in future versions.


Install @editablejs/models and @editablejs/editor dependencies:

  1. ```bash
  2. npm i --save @editablejs/models @editablejs/editor
  3. ```

Here's a minimal text editor that you can edit:

  1. ```tsx
  2. import * as React from 'react'
  3. import { createEditor } from '@editablejs/models'
  4. import { EditableProvider, ContentEditable, withEditable } from '@editablejs/editor'

  5. const App = () => {

  6.   const editor = React.useMemo(() => withEditable(createEditor()), [])

  7.   return (
  8.   <EditableProvider editor={editor}>
  9.     <ContentEditable placeholder="Please enter content..." />
  10.   </EditableProvider>)
  11. }
  12. ```

Data Model


@editablejs/models provides a data model for describing the state of the editor and operations on the editor state.

  1. ```ts
  2. {
  3.   type: 'paragraph',
  4.   children: [
  5.     {
  6.       type: 'text',
  7.       text: 'Hello World'
  8.     }
  9.   ]
  10. }
  11. ```

As you can see, its structure is very similar to [Slate](https://github.com/ianstormtaylor/slate), and we did not create a new data model, but directly used Slate's data model and extended it (added Grid, List related data structures and operations). Depending on these mature and excellent data structures can make our editor more stable.

We have encapsulated all of Slate's APIs into @editablejs/models, so you can find all of Slate's APIs in @editablejs/models.

If you are not familiar with Slate, you can refer to its documentation: https://docs.slatejs.org/

Plugins


Currently, we provide some out-of-the-box plugins that not only implement basic functionality, but also provide support for keyboard shortcuts, Markdown syntax, Markdown serialization, Markdown deserialization, HTML serialization, and HTML deserialization.

Common Plugins


- @editablejs/plugin-context-menu provides a right-click menu. Since we do not use some of the functionality of the native contenteditable menu, we need to define our own right-click menu functionality.
- @editablejs/plugin-align for text alignment
- @editablejs/plugin-blockquote for block quotes
- @editablejs/plugin-codeblock for code blocks
- @editablejs/plugin-font includes font color, background color, and font size
- @editablejs/plugin-heading for headings
- @editablejs/plugin-hr for horizontal lines
- @editablejs/plugin-image for images
- @editablejs/plugin-indent for indentation
- @editablejs/plugin-leading for line spacing
- @editablejs/plugin-link for links
- @editablejs/plugin-list includes ordered lists, unordered lists, and task lists
- @editablejs/plugin-mark includes bold, italic, strikethrough, underline, superscript, subscript, and code
- @editablejs/plugin-mention for mentions
- @editablejs/plugin-table for tables

The usage method of a single plugin, taking plugin-mark as an example:

  1. ```tsx
  2. import { withMark } from '@editablejs/mark'

  3. const editor = React.useMemo(() => {
  4.   const editor = withEditable(createEditor())
  5.   return withMark(editor)
  6. }, [])
  7. ```

You can also use the following method to quickly use the above common plugins via withPlugins in @editablejs/plugins:

  1. ```tsx
  2. import { withPlugins } from '@editablejs/plugins'

  3. const editor = React.useMemo(() => {
  4.   const editor = withEditable(createEditor())
  5.   return withPlugins(editor)
  6. }, [])
  7. ```

History Plugin


The @editablejs/plugin-history plugin provides undo and redo functionality.

  1. ```tsx
  2. import { withHistory } from '@editablejs/plugin-history'

  3. const editor = React.useMemo(() => {
  4.   const editor = withEditable(createEditor())
  5.   return withHistory(editor)
  6. }, [])
  7. ```

Title Plugin


When developing document or blog applications, we usually have a separate title and main content, which is often implemented using an input or textarea outside of the editor. If in a collaborative environment, since it is independent of the editor, additional work is required to achieve real-time synchronization of the title.

The @editablejs/plugin-title plugin solves this problem by using the editor's first child node as the title, integrating it into the editor's entire data structure so that it can have the same features as the editor.

  1. ```tsx
  2. import { withTitle } from '@editablejs/plugin-title'
  3. const editor = React.useMemo(() => {
  4.   const editor = withEditable(createEditor())
  5.   return withTitle(editor)
  6. }, [])
  7. ```

It also has a separate placeholder property for setting the placeholder for the title.

  1. ```tsx
  2. return withTitle(editor, {
  3.   placeholder: 'Please enter a title'
  4. })
  5. ```

Yjs Plugin


The @editablejs/plugin-yjs plugin provides support for Yjs, which can synchronize the editor's data in real-time to other clients.

You need to install the following dependencies:

- yjs The core library of Yjs

  @editablejs/yjs-websocket Yjs websocket communication library

  In addition, it also provides the implementation of the nodejs server, which you can use to set up a yjs service:
  ts
  import startServer from '@editablejs/yjs-websocket/server'

  startServer()
  
- @editablejs/plugin-yjs Yjs plugin used with the editor

  1. ```bash
  2. npm i yjs @editablejs/yjs-websocket @editablejs/plugin-yjs
  3. ```

Instructions:


  1. ```tsx
  2. import * as Y from 'yjs'
  3. import { withYHistory, withYjs, YjsEditor, withYCursors, CursorData, useRemoteStates } from '@editablejs/plugin-yjs'
  4. import { WebsocketProvider } from '@editablejs/yjs-websocket'

  5. // Create a yjs document
  6. const document = React.useMemo(() => new Y.Doc(), [])
  7. // Create a websocket provider
  8. const provider = React.useMemo(() => {
  9.   return typeof window === 'undefined'
  10.       ? null
  11.       : new WebsocketProvider(yjsServiceAddress, 'editable', document, {
  12.           connect: false,
  13.         })
  14. }, [document])
  15. // Create an editor
  16. const editor = React.useMemo(() => {
  17.   // Get the content field from yjs document, which is of type XmlText
  18.   const sharedType = document.get('content', Y.XmlText) as Y.XmlText
  19.   let editor = withYjs(withEditable(createEditor()), sharedType, { autoConnect: false })
  20.   if (provider) {
  21.     // Synchronize cursors with other clients
  22.     editor = withYCursors(editor, provider.awareness, {
  23.       data: {
  24.         name: 'Test User',
  25.         color: '#f00',
  26.       },
  27.     })
  28.   }
  29.   // History record
  30.   editor = withHistory(editor)
  31.   // yjs history record
  32.   editor = withYHistory(editor)
  33. }, [provider])

  34. // Connect to yjs service
  35. React.useEffect(() => {
  36.   provider?.connect()
  37.   return () => {
  38.     provider?.disconnect()
  39.   }
  40. }, [provider])

  41. ```


Custom Plugin


Creating a custom plugin is very simple. We just need to intercept the renderElement method, and then determine if the current node is the one we need. If it is, we will render our custom component.

An example of a custom plugin:


  1. ```tsx
  2. import { Editable } from '@editablejs/editor'
  3. import { Element, Editor } from '@editablejs/models'

  4. // Define the type of the plugin
  5. export interface MyPlugin extends Element {
  6.   type: 'my-plugin'
  7.   // ... You can also define other properties
  8. }

  9. export const MyPlugin = {
  10.   // Determine if a node is a plugin for MyPlugin
  11.   isMyPlugin(editor: Editor, element: Element): element is MyPlugin {
  12.     return Element.isElement(value) && element.type === 'my-plugin'
  13.   }
  14. }

  15. export const withMyPlugin = <T extends Editable>(editor: T) => {
  16.   const { isVoid, renderElement } = editor
  17.   // Intercept the isVoid method. If it is a node for MyPlugin, return true
  18.   // Besides the isVoid method, there are also methods such as `isBlock` `isInline`, which can be intercepted as needed.
  19.   editor.isVoid = element => {
  20.     return MyPlugin.isMyPlugin(editor, element) || isVoid(element)
  21.   }
  22.   // Intercept the renderElement method. If it is a node for MyPlugin, render the custom component
  23.   // attributes are the attributes of the node, we need to pass it to the custom component
  24.   // children are the child nodes of the node, which contains the child nodes of the node. We must render them
  25.   // element is the current node, and you can find your custom properties in it
  26.   editor.renderElement = ({ attributes, children, element }) => {
  27.     if (MyPlugin.isMyPlugin(editor, element)) {
  28.       return <div {...attributes}>
  29.         <div>My Plugin</div>
  30.         {children}
  31.         </div>
  32.     }
  33.     return renderElement({ attributes, children, element })
  34.   }

  35.   return editor
  36. }
  37. ```


Serialization


@editablejs/serializer provides a serializer that can serialize editor data into html, text, and markdown formats.

The serialization transformers for the plugins provided have already been implemented, so you can use them directly.

HTML Serialization


  1. ```tsx
  2.   // html serializer
  3. import { HTMLSerializer } from '@editablejs/serializer/html'
  4. // import the HTML serializer transformer of the plugin-mark plugin, and other plugins are the same
  5. import { withMarkHTMLSerializerTransform } from '@editablejs/plugin-mark/serializer/html'
  6. // use the transformer
  7. HTMLSerializer.withEditor(editor, withMarkHTMLSerializerTransform, {})
  8. // serialize to HTML
  9. const html = HTMLSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello', bold: true }] })
  10. // output:

    hello

  11. ```


Text Serialization


  1. ```tsx
  2. // text serializer
  3. import { TextSerializer } from '@editablejs/serializer/text'
  4. // import the Text serializer transformer of the plugin-mention plugin
  5. import { withMentionTextSerializerTransform } from '@editablejs/plugin-mention/serializer/text'
  6. // use the transformer
  7. TextSerializer.withEditor(editor, withMentionTextSerializerTransform, {})
  8. // serialize to Text
  9. const text = TextSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello' }, {
  10.   type: 'mention',
  11.   children: [{ text: '' }],
  12.   user: {
  13.     name: 'User',
  14.     id: '1',
  15.   },
  16. }] })
  17. // output: hello @User
  18. ```


Markdown Serialization


  1. ```tsx
  2. // markdown serializer
  3. import { MarkdownSerializer } from '@editablejs/serializer/markdown'
  4. // import the Markdown serializer transformer of the plugin-mark plugin
  5. import { withMarkMarkdownSerializerTransform } from '@editablejs/plugin-mark/serializer/markdown'
  6. // use the transformer
  7. MarkdownSerializer.withEditor(editor, withMarkMarkdownSerializerTransform, {})
  8. // serialize to Markdown
  9. const markdown = MarkdownSerializer.transformWithEditor(editor, { type: 'paragraph', children: [{ text: 'hello', bold: true }] })
  10. // output: **hello**
  11. ```


Every plugin requires importing its own serialization converter, which is cumbersome, so we provide the serialization converters for all built-in plugins in @editablejs/plugins.

  1. ```tsx
  2. import { withHTMLSerializerTransform } from '@editablejs/plugins/serializer/html'
  3. import { withTextSerializerTransform } from '@editablejs/plugins/serializer/text'
  4. import { withMarkdownSerializerTransform, withMarkdownSerializerPlugin } from '@editablejs/plugins/serializer/markdown'

  5. useLayoutEffect(() => {
  6.   withMarkdownSerializerPlugin(editor)
  7.   withTextSerializerTransform(editor)
  8.   withHTMLSerializerTransform(editor)
  9.   withMarkdownSerializerTransform(editor)
  10. }, [editor])
  11. ```

Deserialization


@editablejs/serializer provides a deserializer that can deserialize data in html, text, and markdown formats into editor data.

The deserialization transformers for the plugins provided have already been implemented, so you can use them directly.

The usage is similar to serialization, except that the package path for importing needs to be changed from @editablejs/serializer to @editablejs/deserializer.

Contributors ✨


Welcome 🌟 Stars and 📥 PRs! Let's work together to build a better rich text editor!

The contributing guide is here, please feel free to read it. If you have a good plugin, please share it with us.

Special thanks to Sparticle for their support and contribution to the open source community.
sparticle

Finally, thank you to everyone who has contributed to this project! (emoji key):

Kevin Lin
Kevin Lin

💻
kailunyao
kailunyao

💻
ren.chen
ren.chen

📖
han
han

📖



This project follows the all-contributors specification. Contributions of any kind welcome!

Thanks


We would like to thank the following open-source projects for their contributions:

- Slate - provides support for data modeling.
- Yjs - provides basic support for CRDTs, used for collaborative editing support.
- React - provides support for the view layer.
- Zustand - a minimal front-end state management tool.

We use the following open-source projects to help us build a better development experience:

- Turborepo -- pnpm + turbo is a great monorepo manager and build system.

License


See LICENSE for details.