Tiptap 之自定义脚注组件

Tiptap 的强大之处在于其扩展机制,开发者可以通过以下方式自定义功能:

  1. 继承现有扩展:通过 extend 方法扩展现有节点或标记
  2. 创建新扩展:定义全新的节点或标记类型
  3. 添加属性:为现有扩展添加自定义属性
  4. 重写方法:覆盖默认的行为实现

脚注组件Footnote

脚注组件 Footnote 是通过第二种方式,即创建新扩展实现的。总体参照 LinkPopover 组件改造,完成上标及悬浮提示的功能。

创建脚注组件扩展

jsx 复制代码
// extension-footnote.ts
import { Node, mergeAttributes } from "@tiptap/core";

export interface FootnoteOptions {
  HTMLAttributes: Record<string, any>;
}

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    footnote: {
      /** 设置脚注(插入或更新) */
      setFootnote: (attrs: { text: string; content: string }) => ReturnType;
      /** 移除脚注 */
      unsetFootnote: () => ReturnType;
      /** 更新脚注 */
      updateFootnote: (attrs: { text: string; content: string }) => ReturnType;
    };
  }
}

export const Footnote = Node.create<FootnoteOptions>({
  name: "footnote", // 节点唯一标识
  group: "inline", // 属于行内元素组,可嵌入文本中
  inline: true, // 行内节点
  atom: true, // 原子节点,不可拆分
  selectable: true, // 可被选中

  addAttributes() {
    return {
      // 脚注符号(上标显示的内容,如①②③④⑤等)
      text: {
        default: "", // 默认符号
        parseHTML: (element) => element.getAttribute("data-text"),
        renderHTML: (attrs) => ({ "data-text": attrs.text }),
      },
      // 脚注内容(悬浮提示/编辑内容)
      content: {
        default: "",
        parseHTML: (element) => element.getAttribute("data-content"),
        renderHTML: (attrs) => ({ "data-content": attrs.content }),
      },
    };
  },

  // 解析规则:识别带data-footnote属性的sup标签
  parseHTML() {
    return [
      {
        tag: "sup[data-footnote]",
        getAttrs: (dom) => {
          if (typeof dom !== "object") return false;
          const element = dom as HTMLElement;
          return {
            text: element.getAttribute("data-text"),
            content: element.getAttribute("data-content"),
          };
        },
      },
    ];
  },

  // 渲染逻辑:上标标签+自定义符号+内容属性
  renderHTML({ node, HTMLAttributes }) {
    const { text, content } = node.attrs;

    return [
      "sup", // 使用上标标签,符合脚注排版习惯
      mergeAttributes(
        this.options.HTMLAttributes,
        {
          "data-footnote": "", // 标识为脚注节点
          "data-text": text,
          "data-content": content,
          class: "footnote-marker",
          // title: content,
        },
        HTMLAttributes,
      ),
      text, // 显示脚注符号
    ];
  },

  // 插入命令:接收符号和内容参数
  addCommands() {
    return {
      setFootnote:
        (attrs: { text: string; content: string }) =>
        ({ commands }) => {
          return commands.insertContent({
            type: this.name,
            attrs,
          });
        },
      unsetFootnote:
        () =>
        ({ commands }) => {
          return commands.deleteSelection();
        },
    };
  },

  addKeyboardShortcuts() {
    return {
      "Mod-Shift-F": () => {
        return this.editor.commands.setFootnote({
          text: "①",
          content: "请输入脚注内容",
        });
      },
    };
  },
});
  1. 编辑器配置
jsx 复制代码
const editor = useEditor({ extensions: [Footnote] });
  1. 命令创建
jsx 复制代码
// 插入脚注
editor.commands.setFootnote({
  text: "①",
  content: "这是脚注内容",
});

// 移除脚注(需要先选中脚注节点)
editor.commands.unsetFootnote();
  1. JSON 数据初始化
json 复制代码
// 上标
{
  "type": "text",
  "marks": [{ "type": "superscript" }],
  "text": "②"
},
// 脚注
{
  "type": "footnote",
  "attrs":
    "text": "②",
    "content": "这是脚注内容"
},
  1. 渲染效果

如下所示:脚注内容是通过 title 属性显示的,使用的浏览器默认样式,需要优化

解析源码:

脚注弹框组件

整体依照 LinkPopover 组件改造

使用到了文本框组件 TextareaAutosize,需要先安装一下,样式我也调整了一下,参考Input组件对齐:

bash 复制代码
npx @tiptap/cli@latest add textarea-autosize

选中文本初始化标记

默认情况下,脚注标记和脚注内容都是空的;如果选中文本后,再点击脚注组件,则会将选中的文本作为脚注标记,自动填充进去。

jsx 复制代码
const setFootnote = React.useCallback(() => {
  if (!text || !editor) return;

  const { selection, doc } = editor.state;
  // 获取选中文本
  const selectedText = doc.textBetween(selection.from, selection.to, "\n");
  // 文本赋值
  const finalText = selectedText || text;

  let chain = editor.chain().focus();

  // 如果已经选中了脚注,就更新它
  if (isFootnoteActive(editor)) {
    chain = chain.updateFootnote({ text: finalText, content });
  } else {
    // 否则插入新的脚注
    chain = chain.setFootnote({ text: finalText, content });
  }

  chain.run();
  onSetFootnote?.();
}, [editor, onSetFootnote, text, content]);
jsx 复制代码
React.useEffect(() => {
  if (!editor) return;

  const updateFootnoteState = () => {
    const { selection, doc } = editor.state;
    // 提取选中的文本
    const selectedText = doc.textBetween(selection.from, selection.to, "\n");

    const { text: curText, content: curContent } =
      editor.getAttributes("footnote");

    // 如果有选中的文本且当前不是编辑已有脚注,自动填充到 text
    if (selectedText && !isFootnoteActive(editor)) {
      setText(selectedText);
    } else {
      setText(curText || "");
    }
    setContent(curContent || "");
  };

  editor.on("selectionUpdate", updateFootnoteState);
  return () => {
    editor.off("selectionUpdate", updateFootnoteState);
  };
}, [editor]);

行首插入问题

父节点是 h1,在行首插入脚注时,会将父节点变成 p 标签,导致类型都变了。

  • 问题原因

当光标位于行首且没有选中任何内容时,insertContent 会尝试在当前块级节点(如 H1)的最开始插入脚注节点。如果 H1 的 schema 约束不够宽松,编辑器可能会为了兼容插入的节点而修改父节点类型。

这种方式在行首空选择时可能会破坏父节点(如 H1)的结构约束,导致编辑器自动将 H1 降级为 P 标签。

  • 问题解决:先插入一个空文本节点

零宽空格 Unicode: \u200B

jsx 复制代码
chain = chain
  .insertContent("​") // 解决插入行首时,将h1改成p了
  .setFootnote({ text: finalText, content });

上面代码可以解决,但是每次都插入一个零宽空格,也不好,需要继续优化setFootnote命令。

目前没找到更好的方法,只能这样了。。。。。。

提示优化

默认使用的title属性显示脚注内容,但是这样无法实现点击时弹出提示框,需要自定义处理addNodeView

jsx 复制代码
// 修改extension-footnote.tsx代码
const FootnoteView = ({ node }: any) => {
  const { text, content } = node.attrs;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <sup data-footnote="">{text}</sup>
      </TooltipTrigger>
      <TooltipContent>
        <p>{content}</p>
      </TooltipContent>
    </Tooltip>
  );
};

// 绑定自定义 NodeView:不行,sup都变成span了
addNodeView() {
  return ReactNodeViewRenderer(FootnoteView);
},

上述方法不行,元素都被改变了,sup 变成 span 了

还是回归最原始的方法了,更改 鼠标悬浮时title提示的样式

相关推荐
时光足迹2 小时前
Tiptap之造字组件
前端·javascript·react.js
小四的小六2 小时前
WebView 兼容性踩坑实录:那些让我加班的坑
javascript·webview
jump_jump2 小时前
用官方模板理解 Decky 插件:一次从模板到架构的速览
javascript·python·游戏
张元清2 小时前
React 表单处理:防抖校验、自动保存草稿与受控输入
前端·javascript·面试
Lee川2 小时前
React 首页秒开优化:用 KeepAlive 实现丝滑的页面缓存
前端·react.js
Hilaku2 小时前
给技术团队定规范,为什么 90% 最后都变成了走形式?
前端·javascript·程序员
小番茄夫斯基2 小时前
Node.js 从零开发 MCP 服务:30 分钟上手,对接 Claude/Cursor 全流程
前端·mcp
LIO2 小时前
一套代码,多端并行——uni-app + Vue3 多端开发完全指南
前端·vue.js·uni-app
众创岛2 小时前
web自动化中的日志模块
java·前端·自动化