You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

GrapesJS集成Tiptap富文本编辑器遇问题求解决方案

求问:是否有开发者成功将Tiptap集成为GrapesJS的富文本编辑器?

GrapesJS官方提供的自定义RTE集成示例极少,我在尝试将Tiptap作为它的富文本编辑器时,遇到了样式/继承冲突、换行异常、换行文本随机加粗等问题。希望能获取相关的集成经验或代码示例,以下是我目前尝试的代码:

import { Editor, Node } from "@tiptap/core";
import type { Editor as GrapesEditor } from "grapesjs";
import { TextStyle, FontFamily, FontSize, LineHeight } from "@tiptap/extension-text-style";
import Underline from "@tiptap/extension-underline";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import TextAlign from "@tiptap/extension-text-align";

import Heading from "@tiptap/extension-heading";
import Text from "@tiptap/extension-text";
import Bold from "@tiptap/extension-bold";
import Italic from "@tiptap/extension-italic";
import Strike from "@tiptap/extension-strike";
import Color from "@tiptap/extension-color";
import { ListKit } from "@tiptap/extension-list";
import { Token } from "../extensions/token";

function removeEmptyPTags(htmlContent: string = ""): string {
  // 移除无内容的p标签(包括带style等属性的)
  return htmlContent?.replace(/<p[^>]*>\s*<\/p>/gi, '');
}

function stringReplacement(htmlContent: string = "") {
  const firstResult = htmlContent?.replace(/^\s*<p>\s*([\s\S]*?)\s*<\/p>\s*$/i, '$1');
  const secondResult = firstResult?.replace(/<p>\s*<\/p>/gi, '<br/>');
  const thirdResult = secondResult?.replace("<br/>", '');
  return thirdResult;
}

function propagateStyles(el: HTMLElement, htmlContent: string): string {
  // 获取父元素的计算样式
  const parentComputedStyle = window?.getComputedStyle(el);

  // 提取需要继承的文本样式
  const stylesToPropagate = {
    fontFamily: parentComputedStyle?.fontFamily,
    fontSize: parentComputedStyle?.fontSize,
    fontWeight: parentComputedStyle?.fontWeight,
    fontStyle: parentComputedStyle?.fontStyle,
    color: parentComputedStyle?.color,
    lineHeight: parentComputedStyle?.lineHeight,
    letterSpacing: parentComputedStyle?.letterSpacing,
    // textAlign: parentComputedStyle.textAlign,
    textDecoration: parentComputedStyle?.textDecoration,
  };

  // 创建临时容器解析HTML
  const tempDiv = document?.createElement('div');
  tempDiv.innerHTML = htmlContent;

  // 为所有p、div、span元素应用父元素样式(仅当元素本身无该样式时)
  const elements = tempDiv?.querySelectorAll('p, div, span');
  elements?.forEach((el) => {
    const element = el as HTMLElement;
    Object.entries(stylesToPropagate)?.forEach(([key, value]) => {
      if (value && value !== 'none') {
        // 将驼峰命名转为短横线命名的CSS属性
        const cssKey = key?.replace(/([A-Z])/g, '-$1')?.toLowerCase();
        if (!element?.style?.getPropertyValue(cssKey)) {
          element?.style?.setProperty(cssKey, value);
        }
      }
    });
  });

  return tempDiv?.innerHTML;
}

const CustomParagraph = Paragraph.extend({
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('id'),
        renderHTML: attributes => {
          if (!attributes.id) return {}
          return { id: attributes.id }
        },
      },
      class: {
        default: null,
        parseHTML: element => element.getAttribute('class'),
        renderHTML: attributes => {
          if (!attributes.class) return {}
          return { class: attributes.class }
        },
      },
      style: {
        default: null,
        parseHTML: element => element.getAttribute('style'),
        renderHTML: attributes => {
          if (!attributes.style) return {}
          return { style: attributes.style }
        },
      },
    }
  },
});

const CustomHeading = Heading.extend({
  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: element => element.getAttribute('id'),
        renderHTML: attributes => {
          if (!attributes.id) return {}
          return { id: attributes.id }
        },
      },
      class: {
        default: null,
        parseHTML: element => element.getAttribute('class'),
        renderHTML: attributes => {
          if (!attributes.class) return {}
          return { class: attributes.class }
        },
      },
      style: {
        default: null,
        parseHTML: element => element.getAttribute('style'),
        renderHTML: attributes => {
          if (!attributes.style) return {}
          return { style: attributes.style }
        },
      },
    }
  },
});

const CustomDiv = Node.create({
  name: "customDiv",

  group: "block", // 将div视为块级元素
  content: "inline*", // 仅允许行内元素在div内(不允许p标签)
  // content: 'block*',

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

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

export const TipTapPlugin = (editor: GrapesEditor, options: { rteInstance: Editor, setRteInstance: (instance: any) => void }) => {
    const { setRteInstance } = options;

    editor.setCustomRte({
        // parseContent: true, // 此配置会引发问题
        focus: (el: HTMLElement, rte: Editor) => {
        // 如果已聚焦则不处理
            if (rte && rte?.isFocused) {
                return;
            }
            el.setAttribute("contenteditable", "true");
            rte && rte.commands.focus("all", { scrollIntoView: true });
        },
        enable: (el, rte: Editor | null) => {
        // 清理已存在的编辑器实例
            if (rte) {
                try {
                    rte.destroy();
                    setRteInstance(null);
                } catch (e) {
                    console.warn(e);
                }
            }
            console.log("TIPTAP enable", { el, rte })

            // 提取内容,处理页面重载后已存在的ProseMirror内容
            const content = el.querySelector("div.ProseMirror > p")?.innerHTML || el.innerHTML;
            const currentHTML = content;
            const secondResult = removeEmptyPTags(currentHTML);
            // 移除所有<br>标签(包括<br/>和<br />)
            const cleanedHTML = secondResult?.replace("<br/>", "");

            el.innerHTML = ""; // 清空元素避免内容重复

            // 初始化TipTap编辑器
            rte = new Editor({
                element: el,
                extensions: [
                  Document,
                  CustomDiv,
                  CustomParagraph,
                  Text,
                  // CustomTextStyle,
                  // CustomSpan,
                  TextStyle,
                  CustomHeading,
                  Bold,
                  Italic,
                  Strike,
                  Underline,
                  Color,
                  FontFamily,
                  FontSize,
                  LineHeight,
                  ListKit,
                  TextAlign.configure({
                    types: ["heading", "paragraph"]
                  }),
                  Token,
                ],
                content: content,
                injectCSS: false,
                onCreate: ({ editor }) => {
                    const currentHTML = editor?.getHTML();
                    const secondResult = removeEmptyPTags(currentHTML);
                    // 移除所有<br>标签(包括<br/>和<br />)
                    const cleanedHTML = secondResult?.replace("<br/>", "");
                    editor.commands.setContent(cleanedHTML);
                    console.log("TIPTAP onCreate", { cleanedHTML, editor })
                    editor.commands.focus("all", { scrollIntoView: true });
                    // 不使用requestAnimationFrame时工作正常
                    // requestAnimationFrame(() => {
                    //     editor.commands.focus();
                    //     editor.commands.focus("all", { scrollIntoView: true });
                    // });
                    // 获取当前HTML内容
                }
            });

            setRteInstance(rte);
            return rte;
        },

        disable: (el, rte: Editor | null) => {
            console.log("TIPTAP disable", { el, rte })
            el.setAttribute("contenteditable", "false");
            // const htmlContent = rte?.getHTML() || "";
            // const contentWithoutWrapper = stringReplacement(htmlContent) || el.innerHTML;
            // const contentWithStyles = propagateStyles(el, contentWithoutWrapper);

            rte?.commands?.blur();
            rte?.destroy();
            setRteInstance(null);
            // el.innerHTML = contentWithStyles;
            // return rte;
        },

        getContent: (el, rte: Editor | null) => {
            console.log("TIPTAP getContent", { el, rte })
            // 此逻辑会引发问题
            const htmlContent = rte?.getHTML() || "";
            el.innerHTML = htmlContent;
            const contentWithoutWrapper = stringReplacement(htmlContent) || el.innerHTML;
            const contentWithStyles = propagateStyles(el, contentWithoutWrapper);
            el.innerHTML = contentWithStyles;
            return el.innerHTML;
        }
    });
};

内容的提问来源于stack exchange,提问作者Abhinav Thapa

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.11 15:40:55