基于tiptap实现微信文章编辑器(上)

基于 tiptap 实现微信文章编辑器

Tiptap 编辑器是一款无头、与框架无关的富文本编辑器,可通过 extension 进行定制和扩展。

它的特点包括:

  • headless:Tiptap 是无头的,意味着它没有固定的用户界面,无任何预设样式。允许开发者打造任何样式的编辑器。

  • 基于 ProseMirror:Tiptap 是基于 ProseMirror 构建的,ProseMirror 是一个强大的编辑器框架,这使得 tiptap 简单易用的同时具备强大潜力。

  • 可扩展:Tiptap 可以通过 extension 进行定制和扩展,你可以在编辑器中定义任何内容特性和编辑器行为。官方提供了一些常用的 extension,如链接、列表、表格等。还包括 AI 增强集成、基于 Y.js 的多人协作等功能 extension;

官方网站:www.tiptap.dev

api 文档:tiptap.dev/docs/editor...

github: github.com/ueberdosis/...

前面的话

本文以使用 tiptap 实现微信文章编辑器为例,逐步介绍 tiptap 使用与核心概念。

项目地址:github.com/KID-1912/ti...

在线示例:kid-1912.github.io/tiptap-appm...

为什么选择 tiptap ?

由于需要完全自定义编辑界面,诸如 wangEditor 等需要覆盖默认样式的富文本编辑器很难实现; 最开始尝试使用 quill,一个轻便的富文本编辑器; quill 基于 Parchment 的文档模型,不允许直接插入/修改 dom,只能通过声明新的 Parchment Blots 实现输入解析规则与输出渲染规则(基于 ProseMirror 的 tiptap 与之相似) 但 quill 的 Blots 声明项繁杂且相关文档说明很少,很难为编辑器添加新的内容特性。而 tiptap 通过 extension 可以很好的实现这一点。

开始搭建

构造 editor 实例

js 复制代码
import { Editor } from "https://esm.sh/@tiptap/core"; // @tiptap/core
import StarterKit from "https://esm.sh/@tiptap/starter-kit"; // @tiptap/starter-kit

const editor = new Editor({
  element: document.querySelector("#editor"),
  extensions: [StarterKit],
});

Editor 是 tiptap 的核心类,用于创建编辑器实例。element 选项指定编辑器的容器元素,extensions 选项指定编辑器的扩展。

StarterKit 是 tiptap 提供的入门套件 extension,它包含了所有常用的编辑器功能

由于 tiptap editor 默认在页面无任何样式,添加一些自定义样式后,预览效果看这里

核心概念

Command

通过 extension 提供的指令,可以实现快速对内容执行预定义操作

以用于加粗的 @tiptap/extension-bold extension 为例,由于已包含在入门套件,直接使用命令即可

js 复制代码
const handleBold = () => {
  editor.commands.toggleBold(); // setBold unsetBold
};

页面新增加粗按钮,绑定加粗处理到点击事件,预览效果看这里

你可以在官方文档的 NodesMarksExtensions 3 个章节查看其它 extension 各自提供的 command,为你的编辑器添加新功能

同时可以参阅 微信文章编辑器 toolbar.js 查看编辑器工具栏功能的实现

editor 命令

除了 extension 封装过的命令,编辑器(editor)提供了大量基础命令,允许添加、更改内容或改变选区。见文档 (command)[tiptap.dev/docs/editor...] 章节

其中 insertContentupdateAttributes 是常用基础命令

如官方提供用于插入图片内容的 @tiptap/extension-image 拓展,提供 setImage 命令插入图片:

js 复制代码
editor.commands.setImage({ src: "https://example.com/logo.png" }); // 插入图片

setImage 命令源代码实现:

js 复制代码
// addCommand选项: extension向外暴露的命令
addCommands() {
  return {
    setImage: options => ({ commands }) => {
      return commands.insertContent({ // 内部依旧调用 insertContent 基础命令
        type: this.name,
        attrs: options,
      })
    },
  }
},

因此,下面两行代码互相等价

js 复制代码
editor.commands.setImage({ src: "https://example.com/logo.png" });
// 等于
editor.commands.insertContent({
  type: "image", // @tiptap/extension-image `name` 选项值
  attrs: { src: "https://example.com/logo.png" },
});

insertContent 支持一次插入嵌套的内容、支持插入 HTML 形式内容,详见 insert-content

链式调用

editor.chain() 命令提供命令链调用

js 复制代码
editor
  .chain() // 开启链式命令
  .focus() // 聚焦编辑区,保留选区选中样式
  .toggleBold() // 若干命令链接
  ...
  .run() // 运行

extension

extension 是 tiptap 的核心概念,它是一种可扩展的编辑器功能,可添加新的节点、标记、插件、指令等

在我们编写 extension 时,建议反复查阅文档 Custom Extensions 章节

extension 分类

根据 extension 的作用,大致分为这 3 种类型拓展

Node

创建一个新节点类型,即文档支持一个新的内容类型

js 复制代码
import { Node } from "@tiptap/core";
const Video = Node.create({
  type: "video",
  renderHTML(){ ... },
  parseHTML(){ ... }
})

Mark

可以对节点应用一个或多个标记,常见为文本添加内联样式(textStyle)

js 复制代码
import { Mark } from "@tiptap/core";
const FontSize = Mark.create({
  name: "fontSize",
  ...
})

Extension

以上 2 种类型都基于 Extension 基础类,通过定义基础的 extension 添加全局特性

新增内容浮动(float)特性

js 复制代码
import { Extension } from "@tiptap/core";
const Float = Extension.create({
  name: "float",
  addGlobalAttributes() { ... }
  ...
})

extension 核心选项

name

扩展名称,代表内容类型/特性唯一名称

js 复制代码
// Node类型extension
editor.commands.insertContent({
  type: "image", // 'image' 即 @tiptap/extension-image中name选项值
  attrs: { src: "https://example.com/logo.png" },
});

// Mark类型extension
editor.commands.setMark("bold"); // 'bold' 即 @tiptap/extension-bold中name选项值

// 基础extension
editor.commands.updateAttributes(
  "paragraph",
  { textAlign: alignment } // textAlign 即 @tiptap/extension-text-align中name选项值
);

group

定义节点所属的内容组,值可以是 block/inline/有效type值,供 content 选项引用

content

定义节点可以包含的内容类型。不符合的内容会被丢弃

js 复制代码
// 必须一个或多个内容块(group选项值为block)
content: 'block+',

// 必须零个或多个区块
content: 'block*',

// 允许所有内联内容(group选项值为inline)
content: 'inline*',

// 仅文本内容
content: 'text*',

// 可以有一个或多个段落,或列表(如果使用列表)
content: '(paragraph|list?)+',

// 顶部必须有一个标题,下面必须有一个或多个区块
content: 'heading block+'

inline

节点是否内联显示。为 true 时,节点会与文本一起并列行呈现。

addOptions

声明 extension 使用时配置项,供拓展使用者控制 extension 行为

@tiptap/extension-imageaddOptions 选项:

js 复制代码
  addOptions() {
    return {
      inline: false,
      allowBase64: false,
      HTMLAttributes: {},
    }
  },
  // 其它选项内通过 `this.options` 访问参数值,进行不同处理
  group() {
    return this.options.inline ? 'inline' : 'block'
  },
  parseHTML() {
    return [
      {
        tag: this.options.allowBase64
          ? 'img[src]'
          : 'img[src]:not([src^="data:"])',
      },
    ]
  },

@tiptap/extension-image 配置项说明 使用时可以自行配置拓展 options 默认值,

js 复制代码
import Image from "@tiptap/extension-image";

const editor = new Editor({
  element: document.querySelector(".editor"),
  extensions: [Image.configure({ inline: true, allowBase64: true })],
});

addAttributes

设置节点/标记状态,注意到它返回一个函数,即为每个节点/标记实例添加独立状态

js 复制代码
// `@tiptap/extension-image`
addAttributes() {
  return {
    src: {  // image 节点新增 src 属性
      default: null,
    },
    alt: {  // image 节点新增 alt 属性
      default: null,
    },
    title: {  // image 节点新增 title 属性
      default: null,
    },
  }
},

默认未添加额外声明时,tiptap 节点属性(attributes)会作为 DOM HTMLAttributes,渲染到 DOM 节点上。

同时,你也可以通过 renderHTML 如何消费你声明的属性,自定义渲染输出;也可以通过 parseHTML 定义外部输入时(向 editor 插入 HTML 或粘贴)如何解析出属性值。

js 复制代码
// @tiptap/extension-highlight 文字高亮
addAttributes() {
  return {
    color: {
      default: null,
      // 当外部内容时检查 data-color 或 样式背景颜色 解析为节点color属性
      parseHTML: element => element.getAttribute('data-color') || element.style.backgroundColor,
      // 消费节点color属性
      renderHTML: attributes => {
        if (!attributes.color) {
          return {}
        }
        return {
          'data-color': attributes.color, // 作为DOM节点 data-color HTMLAttributes
          style: `background-color: ${attributes.color}; color: inherit`, // 作为DOM节点 背景色样式
        }
      },
    },
  }
},

如果只想新增一个单纯状态,避免默认作为 DOM HTMLAttributes,设置 rendered: false 即可

js 复制代码
// @tiptap/extension-heading
addAttributes() {
  return {
    level: {
      default: 1,
      rendered: false, // level 不出现在DOM节点上
    },
  }
},

还记得前面提到的 editor 基础命令 updateAttributes 吗?它就是用来更新节点属性的

js 复制代码
// 更换图片的src地址
editor.commands.updateAttributes("image", {
  src: "https://example.com/logo.png",
});
// 切换标题级别
editor.commands.updateAttributes("heading", { level: 2 });
...

addGlobalAttributes

在 Mark/Node 类型 extension 中 addAttributes 是常见的,它为新增的节点类型声明自己的属性;

但大部分情况我们要基于已有的 Mark/Node 增加新特性。如为段落(paragraph)添加行高支持,为图片(image)和视频(video)支持浮动;

addGlobalAttributes 选项为全局 extension 添加属性,供指定节点/标记使用

js 复制代码
// tiptap-extension-line-height
import { Extension } from "@tiptap/core";

export default Extension.create({
  name: "lineHeight",
  addGlobalAttributes() {
    return [
      {
        types: ["paragraph"], // 仅为段落添加行高
        attributes: {
          lineHeight: {
            default: null,
            parseHTML: (element) => element.style.lineHeight,
            renderHTML: (attributes) => {
              if (!attributes.lineHeight) {
                return {};
              }
              return { style: `line-height: ${attributes.lineHeight}` };
            },
          },
        },
      },
    ];
  },
  addCommands() {
    return {
      setLineHeight:
        (lineHeight) =>
        ({ commands }) => {
          return commands.updateAttributes("paragraph", {
            lineHeight,
          });
        },
    };
  },
});

renderHTML

通过 renderHTML 函数,您可以控制如何将扩展渲染为 HTML,同时也影响 editor.getHTML() 返回值

这与 addAttributes 内的 renderHTML 选项不同,后者用于如何消费 node 属性(attribute),前者用于渲染节点/标记的容器,且此时 DOM 的 HTMLAttribute 已被计算。

js 复制代码
// node 渲染为 strong 标签,并携带默认计算的HTMLAttributes
renderHTML({ HTMLAttributes }) {
  return ['strong', HTMLAttributes, 0]  // HTMLAttributes 即tiptap计算后的DOM属性
},

renderHTML 返回一个数组,第一个值是 HTML 标签名; 如果第二个元素是一个对象,它将被解释为一组属性; 第三个参数 0 用于表示内容应插入的位置;

通过自定义 renderHTML 逻辑,我们可以额外的 HTMLAttributes

js 复制代码
import { mergeAttributes } from '@tiptap/core'

// 渲染为 a 标签,且额外添加 rel 属性,值来自addOptions配置
renderHTML({ HTMLAttributes }) {
  return ['a', mergeAttributes(HTMLAttributes, { rel: this.options.rel }), 0]
},

mergeAttributes 用于合并 2 个表示 HTMLAttributes 的对象,返回一个新的对象

如下,甚至可以将 addOptions 传入自定义的 HTMLAttributes 补充到 renderHTML 中,常见的做法

js 复制代码
renderHTML({ HTMLAttributes }) {
  return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}

// 使用者
const editor = new Editor({
  element: document.querySelector('.editor'),
  extensions: [Bold.configure({ HTMLAttributes: { 'data-format': 'bold' } })],
})

extension 的 DOM 输出不限于简单单个容器,可以是嵌套的内容

js 复制代码
// @tiptap/extension-code-block
renderHTML({ node, HTMLAttributes }) {
  return [
    'pre',
    mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
    [
      'code',
      {
        class: node.attrs.language
          ? this.options.languageClassPrefix + node.attrs.language
          : null,
      },
      0,
    ],
  ]
}

parseHTML

parseHTML 选项用于定义外部 HTML 字符串解析为 Node 的方法,HTML 字符串的未匹配并解析内容将无法插入编辑器

js 复制代码
// @tiptap/extension-code-block
parseHTML() {
  return [
    {
      tag: 'pre', // 将pre标签作为code-block
      preserveWhitespace: 'full',
    },
  ]
},

// @tiptap/extension-bold
parseHTML() {
  // 将满足以下任一条件作为bold
  return [
    {
      tag: 'strong',
    },
    {
      tag: 'b',
      getAttrs: node => (node as HTMLElement).style.fontWeight !== 'normal' && null,
    },
    {
      style: 'font-weight',
      getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
    },
  ]
},

Events

@tiptap/core 提供了一些事件,供 extension 监听并处理

常用事件如下

transaction

transaction 事件在每次编辑器状态(state)变更时触发,可以监听编辑器的状态变化,如内容变更、选区变更等

js 复制代码
editor.on("transaction", ({ editor, transaction }) => {
  // 通过transaction获取编辑器状态变更信息
  console.log(transaction);
});

update

update 事件在编辑器内容变更时触发,可以监听编辑器内容变更

js 复制代码
editor.on("update", () => {
  // 字数实时统计
  const wordCount = editor.getText().length;
});

extension 继承

如前面在 addGlobalAttributes 说到,"大部分情况我们要基于已有的 Mark/Node 增加新特性";

如果只是简单为某些 type extension 新增特性是适合的,但又是我们需要针对某一个extension进行添加特性升值修改部分逻辑(如renderHTML),tiptap提供 Node.extend 以 extension 继承实现

如下为 @tiptap/extension-bullet-list 新增 listStyleType 特性,打造一个支持修改无序列表 list-style 的新 bullet list extension

js 复制代码
// tiptap-extension-bullet-list
import BulletList from "@tiptap/extension-bullet-list";

export default BulletList.extend({
  addAttributes() {
    return {
      ...this.parent?.(), // 沿用BulletList attributes,类似ES6 class中super作用
      listStyleType: {
        default: "disc",
        parseHTML: (element) => {
          const listStyleType = element.style["list-style-type"];
          return { listStyleType: listStyleType || "disc" };
        },
        renderHTML: (attributes) => {
          return { style: `list-style-type: ${attributes.listStyleType}` };
        },
      },
    };
  },
});

this.parent() 可以在 addOptionsaddStorageaddAttributes 等获取到父extension对应配置

如我们有当html 内容中img元素被插入时,将携带的style作为baseStyle attribute存放,然后 mergeStyles处理后最终渲染,

避免大部分携带的style由于未声明解析规则而被 blocked

js 复制代码
import Image from "@tiptap/extension-image";
import { mergeAttributes } from "@tiptap/core";

export default Image.extend({
  name: "image",

  addAttributes() {
    return {
      ...this.parent?.(),
      baseStyle: {
        default: "",
        rendered: false,
        parseHTML: (element) => element.getAttribute("style"),
      },
    };
  },

  renderHTML({ node, HTMLAttributes }) {
    const baseStyle = node.attrs.baseStyle;
    const style = HTMLAttributes.style || "";
    if (style || baseStyle) {
      HTMLAttributes.style = mergeStyles(baseStyle, HTMLAttributes.style);
    }
    return [
      "img",
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
    ];
  },
});

addNodeView

通过添加节点视图,为编辑器添加了交互的或内嵌内容类型

addNodeView 作为一个extension配置,它 renderHTML 有共同点,都能控制节点最终在编辑区渲染结果;

renderHTML 最核心作用是 editor.getHTML 如何将节点转换为html文本用于存储,编辑器默认将renderHTML作为编辑区渲染依据

但节点视图支持开发者自定义一个type node在编辑区上dom内容,如 @tiptap/extension-task-item 实现代码

js 复制代码
addNodeView() {
   return ({
     node, HTMLAttributes, getPos, editor,
   }) => {
     const listItem = document.createElement('li')
     const checkboxWrapper = document.createElement('label')
     const checkboxStyler = document.createElement('span')
     const checkbox = document.createElement('input')
     const content = document.createElement('div')
     checkboxWrapper.contentEditable = 'false'
     checkbox.type = 'checkbox'
     checkbox.addEventListener('change', event => { ... }) // 绑定交互事件
     Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
       listItem.setAttribute(key, value)
     })
     listItem.dataset.checked = node.attrs.checked
     if (node.attrs.checked) {
       checkbox.setAttribute('checked', 'checked')
     }
     checkboxWrapper.append(checkbox, checkboxStyler)
     listItem.append(checkboxWrapper, content)
     Object.entries(HTMLAttributes).forEach(([key, value]) => {
       listItem.setAttribute(key, value)
     })
     return {
       dom: listItem,
       contentDOM: content,
       update: updatedNode => {
         if (updatedNode.type !== this.type) {
           return false
         }
         listItem.dataset.checked = updatedNode.attrs.checked
         if (updatedNode.attrs.checked) {
           checkbox.setAttribute('checked', 'checked')
         } else {
           checkbox.removeAttribute('checked')
         }
         return true
       },
     }
   }
 },

诸如此类,如实现类似微信文章编辑器中的地图卡片、微信公众号卡片等内嵌内容效果,就可以通过这个特性实现(微信采用Web Components)

相关推荐
火山口车神丶12 分钟前
如何借助AI进行模块封装DIY
javascript·人工智能·算法
悟空瞎说1 小时前
收藏即复用!50个极致实用JavaScript单行代码,前端开发效率直接拉满
javascript
薯老板2 小时前
事件循环(Event Loop)
javascript
睿智的海鸥3 小时前
Markdown 语法大全详解
开发语言·前端·javascript·css·html
Highcharts.js3 小时前
用Highcharts如何动态向一个序列添加点
前端·javascript·react.js·highcharts
玖玖passion4 小时前
React 常用 Hooks 函数及使用方法完全指南(useState / useEffect / useRef / useContext / useCallback / useMemo / useReducer)
前端·javascript
TechMasterPlus4 小时前
Hermes 深度解析:React Native 高性能 JavaScript 引擎实践指南
javascript·react native·react.js
VagueVibes4 小时前
Openclaw 快速接入 DeepSeek V4 Pro 指南
javascript
A_nanda4 小时前
VS2022安装QT6.5.3后,如何更新项目配置
前端·javascript·vue.js
heyCHEEMS5 小时前
记录一下自动化构建中 SSE 与子进程管理的三个坑
javascript·node.js