tiptap

一套开源内容编辑和实时协作工具,基于ProseMirror

Posted by page on November 27, 2023

Tiptap

官方文档 中文文档

安装

npm i @tiptap/core @tiptap/starter-kit -S

初始化

import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit"; // 入门套件即常用拓展集合

const editor = new Editor({
  element: document.querySelector(".editor"),
  extensions: [
    StarterKit,
    Underline,
    TextStyle,
    Color,
    FontSize,
    Highlight.configure({ multicolor: true }),
    Image.configure({
      inline: true,
    }),
    ...
  ],
});

Vue

安装npm install @tiptap/vue-3 @tiptap/pm @tiptap/starter-kit

使用

<template>
  <editor-content :editor="editor" />
</template>

<script setup>
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const editor = useEditor({
  content: '<p>I’m running Tiptap with Vue.js. 🎉</p>',
  extensions: [
    StarterKit,
  ],
  onUpdate: () => {
    // emit('update:modelValue', this.editor.getHTML())
  }
}

beforeUnmount(() => editor.value.destory());
</script>

Vue/React更多细节见官方文档

核心

extension

tiptap通过拓展扩充功能,extension包含一般类型拓展、还有Node类型拓展和Mark类型拓展

Extension拓展

export const Ext = Extension.create({
  name: '拓展名称',
  // 如何渲染为HTML
  renderHTML({ HTMLAttributes }) {
    return ['div', HTMLAttributes, 0]
  },
  // 存储属性信息,供解析与渲染时设置属性
  addAttributes() {
    return {
      color: { default: '' }
    }
  }
  // 从HTML内容加载时如何解析
  parseHTML() {
    return [
      { tag: 'strong', },
    ]
  },
  ...schema, // ProseMirror.schema字段,如draggable
  ...
}

如何自定义拓展?

Mark拓展

import { Mark, mergeAttributes } from '@tiptap/core'

export const Underline = Mark.create({
  name: 'underline',
  addOptions() {
    return {
      HTMLAttributes: {},
    }
  },
  parseHTML() {
    return [
      { tag: 'u', },
      {
        style: 'text-decoration',
        consuming: false,
        getAttrs: style => ((style as string).includes('underline') ? {} : false),
      },
    ]
  },
  renderHTML({ HTMLAttributes }) {
    return ['u', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },
  addCommands() {
    return {
      setUnderline: () => ({ commands }) => {
        return commands.setMark(this.name)
      },
      toggleUnderline: () => ({ commands }) => {
        return commands.toggleMark(this.name)
      },
      unsetUnderline: () => ({ commands }) => {
        return commands.unsetMark(this.name)
      },
    }
  },
})

Node拓展

import { Node, mergeAttributes } from "@tiptap/core";

export default Node.create({
  name: "section",
  group: "block",
  content: "block+",

  addAttributes() {
    return {
      style: {
        parseHTML: (element) => element.style.cssText,
        renderHTML: (attributes) => ({ style: attributes.style }),
      },
    };
  },

  parseHTML() {
    return [{ tag: "section" }];
  },

  renderHTML({ HTMLAttributes }) {
    return ["section", mergeAttributes(HTMLAttributes), 0];
  },
});

schema

Tiptap 基于模式定义内容的结构。这样就可以定义文档中可能出现的节点类型、属性以及嵌套方式;

不能使用任何未在模式中定义的 HTML 元素或属性。

import { Node } from '@tiptap/core'

const section = Node.create({
  name: 'section',
  content: 'block+',
  ...node schema
})

Mark.create({
  ...mark.schema
})

Commands

通过tiptap以及拓展提供的命令(commands),实现选区、内容的操作

直接调用

editor.commands.toggleBold();
editor.commands.toggleItalic();

链式调用

editor
  .chain() // 开启链式命令
  .focus() // 聚焦编辑区,防止选区丢失
  .toggleBold() // 若干命令链接 
  .run() // 运行命令链

Editor

API

updateAttributes

editor.chain().focus().updateAttributes("image", { float }).run();

insertContent

editor.commands.insertContent({
  type: "image", // @tiptap/extension-image `name` 选项值
  attrs: { src: "https://example.com/logo.png" },
});
editor
    .chain()
    .focus()
    .insertContent(emojiHTML, {
      parseOptions: {
        preserveWhitespace: false,
      },
    })
    .run();

更多详见官方文档

getAttributes(type)

editor.getAttributes("textStyle").fontSize;
editor.getAttributes("paragraph")?.lineHeight;

selectAll

editor.chain().focus().selectAll().setFontSize(`${size}px`).blur().run();

setContent

editor.commands.setContent(newValue, false); // false 参数表示不记录这次变更到历史记录中

doc

doc.descendants遍历文档

const { state } = this.editor;
const { schema, doc } = state;
const markType = schema.marks.textStyle;
const marksStyle = {
  marks: [],
  nodeMarks: [],
};
doc.descendants((node, pos) => {
  if (node.type.name === 'paragraph') {
    marksStyle.nodeMarks.push({ pos, attrs: node.attrs });
  }
  if (node.isText) {
    const currentMarks = node.marks.filter((mark) => mark.type === markType);
    if (currentMarks.length > 0) {
      currentMarks.forEach((mark) => {
        marksStyle.marks.push({
          pos,
          end: pos + node.nodeSize,
          attrs: mark.attrs,
        });
      });
    }
  }
});

state

view.dispatch(tr) 应用修改

const { state, view } = this.editor;
const { schema, tr } = state;
const markType = schema.marks.textStyle;
marksStyle.nodeMarks.forEach(({ pos, attrs }) => {
  const newAttrs = deepClone(attrs);
  if (attrs.letterSpacing) {
    const oldLetterSpacing = parseInt(attrs.letterSpacing);
    const letterSpacing = parseInt(oldLetterSpacing * ratio);
    newAttrs.letterSpacing = `${letterSpacing}px`;
  }
  if (attrs.lineHeight) {
    const oldLineHeight = parseFloat(attrs.lineHeight);
    const lineHeight = parseFloat((oldLineHeight * ratio).toFixed(1));
    newAttrs.lineHeight = lineHeight;
  }
  tr.setNodeMarkup(pos, null, newAttrs);
});
marksStyle.marks.forEach(({ pos, end, attrs }) => {
  const newAttrs = deepClone(attrs);
  if (attrs.fontSize) {
    const oldFontSize = parseInt(attrs.fontSize);
    const newFontSize = parseInt(oldFontSize * ratio);
    newAttrs.fontSize = `${newFontSize}px`;
  }
  const newMark = markType.create(newAttrs);
  tr.addMark(pos, end, newMark); // 会覆盖原位置mark的attrs
});
view.dispatch(tr);

node

node.type:节点的类型信息,如 node.type.name === 'paragraph'

node.isText/isTextBlock:是否为文本节点/文本块节点

node.attrs:节点的属性,一个键值对的对象

node.textContent:节点的文本内容(包括所有子节点的文本内容)

node.marks:节点的标记(例如加粗、斜体等)

Extensions

内容样式大部分由 marks 直接实现、如加粗高亮等,使用带样式的 mark 标签包裹内容,调用对应 commands 命令操作,attribute 样式信息

文字样式由 text-style 这一mark实现,如文字颜色字体等,使用带样式的 span 标签包裹内容,调用对应 commands 命令操作,attribute.textStyle 样式信息

addNodeView

对renderHTML拓展(getHTML 导出时),控制具体dom

// rendered: false

###