基于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 实例

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 为例,由于已包含在入门套件,直接使用命令即可

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 命令插入图片:

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

setImage 命令源代码实现:

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

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

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() 命令提供命令链调用

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

extension

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

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

extension 分类

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

Node

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

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

Mark

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

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

Extension

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

新增内容浮动(float)特性

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

extension 核心选项

name

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

// 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

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

// 必须一个或多个内容块(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 选项:

  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 默认值,

import Image from "@tiptap/extension-image";

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

addAttributes

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

// `@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 或粘贴)如何解析出属性值。

// @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 即可

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

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

// 更换图片的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 添加属性,供指定节点/标记使用

// 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 已被计算。

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

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

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

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 中,常见的做法

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 输出不限于简单单个容器,可以是嵌套的内容

// @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 字符串的未匹配并解析内容将无法插入编辑器

// @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)变更时触发,可以监听编辑器的状态变化,如内容变更、选区变更等

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

update

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

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

// 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

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 实现代码

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)

原文链接:https://juejin.cn/post/7341690415744958505 作者:黑羽同学

(0)
上一篇 2024年3月3日 下午4:59
下一篇 2024年3月4日 上午10:05

相关推荐

发表回复

登录后才能评论