有了 headless 编辑器,定制化一个编辑器如此简单

1,387 阅读5分钟

一、why?

如果你有自己创建一个编辑器的需求,重新开发一个编辑器,从效率上将显然不是最佳的方案。随着编辑器不断你的发展,无头编辑器也在大力的发展。

什么只听说过 Headless浏览器,其实本质上还真有相似的地方,无头浏览器,使用代码控制,一般需要展示界面。Headless 编辑器也是没有界面。

在开始之前我们还是了解一下富文本基础知识和历史发展吧。

二、基础

基础的富文本实现方案有很多种,以下是实现富文本实现的普遍方案:

编辑

  • iframe
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>iframe designMode Example</title>
    <style>
        #editor {
            width: 100%;
            height: 300px;
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <h1>Editable iframe Example</h1>
    <iframe id="editor"></iframe>

    <script>
        iframe.onload = function() {
          const iframe = document.getElementById('editor');
            iframe.contentDocument.designMode = "on";

            iframe.contentDocument.body.innerHTML = `
                <h2>Editable Content</h2>
                <p>You can edit this content directly in the iframe.</p>
            `;
        };

        iframe.srcdoc = '<html><body></body></html>';
    </script>
</body>
</html>
  • textarea
  • contenteditable 属性

- canvas
- Range 和 Selection

```sh
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Selection and Range Example</title>
    <style>
        #editable {
            border: 1px solid #ccc;
            padding: 10px;
            width: 300px;
            height: 150px;
            overflow: auto;
        }
    </style>
</head>
<body>
    <div id="editable" contenteditable="true">
        This is some editable content. Try selecting some text and then clicking the button below.
    </div>
    <button id="replaceButton">Replace Selected Text</button>
    <script>
        document.getElementById('replaceButton').addEventListener('click', function() {
            let selection = window.getSelection();
            if (selection.rangeCount > 0) {
                let range = selection.getRangeAt(0);
                let newNode = document.createTextNode('Replaced Text');
                range.deleteContents();
                range.insertNode(newNode);
                selection.removeAllRanges();
                let newRange = document.createRange();
                newRange.selectNode(newNode);
                selection.addRange(newRange);
            }
        });
    </script>
</body>
</html>

contenteditable 中配Selection 和 Range API 能实现选择和操作选区工作。

这里在 selection 获取选区,然后在使用 selection 中的 range 对象做 crud 操作。

交互

  • document.execCommand(已经不推荐)
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Simple Rich Text Editor</title>
    <style>
        #editor {
            border: 1px solid #ccc;
            padding: 10px;
            min-height: 200px;
            margin-bottom: 10px;
        }
        .toolbar button {
            margin-right: 5px;
        }
    </style>
</head>
<body>
    <div class="toolbar">
        <button onclick="execCmd('bold')"><b>B</b></button>
        <button onclick="execCmd('italic')"><i>I</i></button>
        <button onclick="execCmd('underline')"><u>U</u></button>
        <button onclick="execCmd('strikeThrough')"><s>S</s></button>
    </div>
    <div id="editor" contenteditable="true"></div>

    <script>
        function execCmd(command, value = null) {
            document.execCommand(command, false, value);
        }
    </script>
</body>
</html>

document.execCommand 虽然不推荐了使用,但是它还是你入门对编辑器思考的重要 api 之一。

数据驱动

数据启动就是不直接操作 DOM,使用数据变化描述 DOM。

Schema

  • Schema 约束

数据库模型抽象常用的一些词汇。如果你熟悉 Mongoose 的第一件事就是写 Schema, 用 Schema 来约束数据表,富文本编辑器也类似,很多边与用户操作的界情况需要考虑,这大概也是富文本最的难点吧。

三、发展

不知道什时候,富文本编辑器也有了等级,通常会被分为以下几种等级

L0

L0 级的主要特点是依赖浏览器 DOM api(contenteditable, document.execCommand), 操作的是 dom 结构,没有将数据单独的抽象出来。像 UEditor、TinyMCE 等。L0 方案开发门槛低,与原生浏览器交互直接流畅,但是存在浏览器不同表现不同,同时不支持一些高级功能(协同等)。

L1

L1 级的主要特点是在 L0 上做了数据的抽象,数据模型描述富文本编辑器内容,不再需要维护 DOM 结构。带有数据驱动意思,与前端主流框架意图相似。但是它们依然依赖 DOM api。目前社区主流编辑器大多都是这种方案,他们有 Slate.jsProseMirrorQuill 等等。

L2 级

L2 基本上就是放弃了 dom 操作, 有的甚至使用 Canvas 全面自己写排版、自己实现内容编辑和交互。基于 Canvas 的编辑器最大的问题就是开发难度大,适合拥有资源的大公司。

对于一般的用户其实 L1 级就够用了。拥有了数据抽象,

四、Headless Editor?

无头编辑器是不含预设用户界面的用户界面文本编辑器框架。他有以下的特点:

  • 没有绑定 UI: 无头编辑器不关注集具体的 UI。
  • 高度可定化:无头编辑器是要用户的需求自己定制外观和交互。
  • 专注于核心功能:专注于文本处理,数据结构,操作和时间处理等
  • API驱动:无头编辑器对外暴露 API,方便开发者进行扩展和集成。
  • 插件支持:无头编辑器使用插件进行支持。

五、社区中的开源方案

L1 级的编辑器到目前开发成本和实用性最合理,以下是社区中常见的富文本编辑器:

  • ProseMirror
  • Tiptap
  • Slate.js
  • Plate.js
  • Lexical
  • Quill
  • ...

在这些编辑器中,其实都处在 L1 级别,其实对一般使用场景就已经够用了,这些编辑器一般都可以实现:

  • 自定义工具条
  • 浮动菜单
  • 斜线菜单
  • 侧边菜单

等一些常用功能,下面我们以 Tiptap 为例在 Next.js 中使用,事件

六、tiptap

Tiptap 替我们屏蔽了底层的知识点,使得使用 Tiptap 非常简单,但是我们要明白底层原理。

tiptap 基于 ProseMirror 目前支持主流前端框架。

以 Next.js 为例,很简单的做一个编辑器功能。tiptap 由核心包和插件组成

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit

Tiptap 在 Next.js 中组成一个编辑器,useEditor 钩子函数和 EditorContent 就可以组合成一个简单的编辑器。

'use client'

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

const Tiptap = () => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌎️</p>',
    onUpdate({ editor }) {
      const html = editor.getHTML();
      onChange?.(html);
    },
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

useEditor 中配置扩展和内容,当然也能监听编辑器变化需要在 useEditor 的 onUpdata 属性中,使用 editor 的getHTML 获取到 html。

当然 Tiptap 的功能远如此。你还可以更加自己的 UI 或者 UI 框架设计符合自己需求的富文本编辑器,甚至实现 AI 聊天功能。。。

七、小结

本文以流行的 headless 编辑器基本讲解富文本编辑器相关知识,包含底层 API, 富文本发展简单历程,headless 浏览器,以及 Tiptap 在 Next.js 中简单实现。当然也有基于 Canvas 底层渲染内容都要自己实现的编辑器,这种虽然最好,但是开发确实需要精兵强将,性价比没有 L1 级别高。