阅读视图

发现新文章,点击刷新页面。

Tiptap之标注组件

Tiptap 图片组件

图片节点Image Node:只能控制基础属性,如 src,alt,title,width, height

增强图片节点Image Node Pro:增加了浮动工具栏控件,可以操作图片对齐方式,具有下载及删除功能

npx @tiptap/cli@latest add image-node-pro

但是组件安装时,需要授权,高级功能吧

tiptap-5-1.png

不想付费的话,只能自己写了,加一个 align 属性控制

按钮可以用官方的Image Align Button

addAttributes() {
  align: {
    default: 'center',
    parseHTML: element => element.getAttribute('data-align') || 'center',
    renderHTML: attributes => {
      return {
        'data-align': attributes.align
      }
    }
  }
}

Tiptap 表格组件

官方文档:Table

# 安装
npm install @tiptap/extension-table
import { TableKit } from "@tiptap/extension-table";

// 注册使用
const editor = useEditor({
  extensions: [
    // 表格扩展
    TableKit.configure({
      table: {
        resizable: true, // 启用列宽调整
      },
    }),
  ],
});

样式代码需要自己加,自己定义:

tiptap-5-2.png

目前只是实现了预览,新增/编辑暂未实现,里面操作逻辑太多了,感觉好难搞

不过Tiptap付费功能好像有,可以直接用

tiptap-5-3.png

Tiptap 标注组件

根据高亮组件Color Highlight改造而成。

编辑器效果如下所示:

tiptap-5-4.png

编辑器渲染代码,如下所示:

tiptap-5-5.png

移除标注

最开始使用如下代码移除标注:

editor.chain().focus().unsetAnnotation().run();

问题:unsetAnnotation 命令默认只对当前选区生效。如果未选中内容(光标在标注内但未选中文本),可能无法移除。

解决方案:selectParentNode或者是extendMarkRange("annotation")移除前强制选中整个标注内容(适合光标在标注内的场景)

const handleRemove = React.useCallback(() => {
  if (!editor || !editor.isEditable) return false;
  if (!canSetAnnotation(editor)) return false;

  // 关键:如果选区为空(光标在标注内),自动选中整个标注节点
  const { from, to } = editor.state.selection;
  const isEmptySelection = from === to;

  const chain = editor.chain().focus();
  // 若选区为空,先选中整个标注节点(确保作用范围)
  if (isEmptySelection) {
    // chain.selectParentNode();
    chain.extendMarkRange("annotation");
  }
  // 执行移除(和高亮的 unsetMark 逻辑一致)
  const success = chain.unsetAnnotation().run();

  if (success) {
    setAnnotationState({ type: defaultType, info: "" });
  }
}, [editor]);

更新标注

添加标注:

editor.chain().focus().setAnnotation(data).run();

更新标注:需要处理「旧标记属性覆盖」和「选区范围」的问题

const handleApply = React.useCallback(() => {
    if (!editor) return false;

    const { type, info } = annotationState;
    const typeData =
      ANNOTATION_TYPES.find((item) => item.value === type) ||
      ANNOTATION_TYPES[0];
    const data = { ...typeData, type, info };

    const { from, to } = editor.state.selection;
    // 无选区(光标在文本中间)
    const isEmptySelection = from === to;
    // 检查当前选区是否已有 annotation 标记
    const isActive = editor.isActive("annotation");

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

    // 若选区为空且光标在标注内,自动选中整个标注
    if (isEmptySelection && isActive) {
      chain.extendMarkRange("annotation");
    }
    // 关键:如果已有标注,先移除旧的,确保新属性能生效
    if (isActive) {
      chain.unsetAnnotation();
    }

    // 应用新的标注属性
    const success = chain.setAnnotation(data).run();
    if (success) {
      onApplied?.(data as AnnotationData);
    }
    return success;
  }, [editor, annotationState, onApplied]);

但是如果旁边也有一个标注,更新时,会把旁边的也同步掉;或者把整行内容都标注了

如果希望改变标注的范围,那么需要先移除原有标注,再在新的选区上设置标注

反之,updateAttributes 只会更新当前选区内已存在的标注,而不会改变标注的范围

但是目前是点击文本,就打开弹框了,而不是选中文本,打开弹框,所以也不太适用

最终,还是得精确当前位置的选区,然后进行操作

import {
  findNodeAtPosition,
  findNodePosition,
  isValidPosition,
} from "@/lib/tiptap-utils";

// 若选区为空且光标在标注内,自动选中整个标注
if (isEmptySelection && isActive) {
  // chain.extendMarkRange("annotation");

  // 1. 验证光标位置有效性
  if (!isValidPosition(from)) return false;

  // 2. 找到光标所在的文本节点(确认在标注内)
  const currentNode = findNodeAtPosition(editor, from);
  if (!currentNode) return false;

  // 3. 找到该文本节点的完整位置范围(避免选中相邻标注)
  const nodePosition = findNodePosition({
    editor,
    node: currentNode,
  });
  if (!nodePosition) return false;

  // 4. 精准选中当前标注的范围
  chain.setTextSelection({
    from: nodePosition.pos,
    to: nodePosition.pos + currentNode.nodeSize, // nodeSize 是节点的长度
  });
}

tiptap-5-6.png

Tiptap之造字组件

Tiptap 自定义扩展

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

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

造字组件

使用继承现有扩展方式创建造字组件,主要用于在文本流中插入和展示那些无法通过常规输入法输入的特殊字符、图标或自定义图形。它实际上是一个特殊的图片节点,用于在文本中插入一个代表特定字符的图片,并且有替换文本(alt)属性

造字组件扩展

造字组件扩展可以直接继承官方 Image 组件,然后添加自定义属性。

需要多一个 glyph 字段就行,能展示替换文本,其实可以直接使用 alt 属性也行。

目前是有两种方案:

  1. 自定义扩展:直接把 extension-image 拷贝过来,在其基础上更改
  2. 继承官方扩展:继承官方 Image 节点,然后添加自定义属性

我选择了第二种,并且直接复用 alt 属性,减少改动,保证稳定性和兼容性。

import { Image as TiptapImage } from "@tiptap/extension-image";
import "./index.scss";

export const GlyphImage = TiptapImage.extend({
  name: "glyphImage",

  addOptions() {
    return {
      ...super.addOptions?.(),
      inline: true, // 强制设置为行内,确保可以在文字中间显示
      HTMLAttributes: { class: "glyph-image" },
    };
  },

  addCommands() {
    return {
      ...super.addCommands?.(),
      // 新增方法
      setGlyphImage:
        (options) =>
        ({ commands }) => {
          return commands.insertContent({ type: this.name, attrs: options });
        },
    };
  },
});
.glyph-image {
  display: inline !important; /* 强制行内显示 */
  /* width: 1em; */
  border: 1px solid #bae6fd; /* 可视化边界 */
}

造字组件使用

import { GlyphImage } from "@/components/tiptap-ui/glyph-image/extension-glyph-image";
// 在编辑器配置中注册组件
const editor = useEditor({ extensions: [GlyphImage] });

// 使用命令插入造字组件
editor.commands.setGlyphImage({
  src: "https://placehold.co/40x40/6A00F5/white",
  alt: "造字替换文本", // 替换文本
  title: "造字标题", // 标题
});

json 数据展示:

{
  "type": "glyphImage",
  "attrs": {
    "src": "/pdf/1-1-2.png",
    "alt": "造字替换文本",
    "title": "造字标题"
  }
},

tiptap-4-1.png

造字组件弹框

同“脚注组件”一样,参照“链接组件”改造:

  • 图片地址 src:可以直接输入地址,也可以上传图片
  • 替换文本 alt:复用 alt 属性作为替换文本,利用 title 属性提供鼠标悬停提示
// glyph-image-popover.tsx文件
const GlyphImageMain: React.FC<GlyphImageMainProps> = ({
  src,
  setSrc,
  alt,
  setAlt,
  setGlyph,
  glyphUpload,
  isActive,
  uploading,
  uploadProgress,
}) => {
  const fileInputRef = React.useRef < HTMLInputElement > null;

  const handleFileChange = async (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const file = event.target.files?.[0];
    if (file) {
      try {
        await glyphUpload(file);
      } finally {
        // 清空input,允许重复选择同一文件
        if (fileInputRef.current) {
          fileInputRef.current.value = "";
        }
      }
    }
  };
  const handleUploadClick = () => {
    if (!uploading) {
      fileInputRef.current?.click();
    }
  };

  return (
    <Card>
      <CardBody>
        <CardItemGroup>
          <Input
            type="url"
            placeholder="输入图片地址(src)"
            value={src}
            onChange={(e) => setSrc(e.target.value)}
          />
          <Input
            type="text"
            placeholder="输入替换文本(alt)"
            value={alt}
            onChange={(e) => setAlt(e.target.value)}
          />

          <ButtonGroup orientation="horizontal" className="justify-end mt-2">
            <Button
              type="button"
              onClick={handleUploadClick}
              title="上传图片"
              data-style="outline"
              disabled={uploading}
            >
              {uploading ? `上传中${Math.round(uploadProgress)}%` : "上传图片"}
            </Button>
            {/* 隐藏的文件输入 */}
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              onChange={handleFileChange}
              style={{ display: "none" }}
            />

            <Button
              type="button"
              onClick={setGlyph}
              title="保存造字"
              disabled={!src && !isActive}
              data-style="outline"
              className="ml-2"
            >
              保存
            </Button>
          </ButtonGroup>
        </CardItemGroup>
      </CardBody>
    </Card>
  );
};

tiptap-4-2.png

图片上传时,不使用 base64 保存图片,而是通过 OSS 保存到阿里云服务器,富文本组件中置保存地址即可

  • use-glyph-image-popover.ts文件

tiptap-4-4.png

tiptap-4-5.png

  • /lib/tiptap-utils.ts 文件

tiptap-4-6.png

最终效果,如下图所示:

tiptap-4-3.png

造字组件高亮问题

选中图片的时候,造字组件是高亮的,需要修复。

tiptap-4-8.png

主要是修改canSetGlyph方法:脚注组件也是类似的,修改canSetFootnote即可

// 检查是否可以设置造字
export function canSetGlyph(editor: Editor | null): boolean {
  // 基础校验:编辑器是否存在或者编辑器是否可编辑
  if (!editor || !editor.isEditable) return false;

  // 节点合法性检测
  // - 检查"glyphImage"节点是否在编辑器的schema中注册(确保功能支持)
  // - 检查当前选中的节点是否为"image"类型(避免与普通图片冲突)
  if (
    !isNodeInSchema("glyphImage", editor) || 
    isNodeTypeSelected(editor, ["image"])
  )
    return false;

  // 最终校验:调用编辑器的can方法检查是否可以执行setGlyphImage命令
  return editor.can().setGlyphImage?.() || false;
}

Tiptap 之自定义脚注组件

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

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

脚注组件Footnote

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

创建脚注组件扩展

// 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. 编辑器配置
const editor = useEditor({ extensions: [Footnote] });
  1. 命令创建
// 插入脚注
editor.commands.setFootnote({
  text: "①",
  content: "这是脚注内容",
});

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

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

tiptap-3-1.png

解析源码:

tiptap-3-2.png

脚注弹框组件

整体依照 LinkPopover 组件改造

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

npx @tiptap/cli@latest add textarea-autosize

tiptap-3-8.png

选中文本初始化标记

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

tiptap-3-3.png

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]);
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

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

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

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

tiptap-3-4.png

提示优化

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

// 修改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 了

tiptap-3-5.png

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

tiptap-3-6.png

tiptap-3-7.png

Tiptap 简单编辑器模版

上一篇介绍了Tiptap 编辑器的基础使用,本篇主要介绍 Tiptap 的简单编辑器模版Simple template,以及如何改造模版。

Tiptap 是一个基于 ProseMirror 的 Headless 富文本编辑器框架,它采用模块化设计,通过扩展(Extensions)机制来实现各种功能。每个扩展可以定义自己的节点(Nodes)、标记(Marks)、命令(Commands)等。

  1. Editor 实例:Tiptap 的核心,管理整个编辑器的状态和行为
  2. Extensions 扩展:功能模块的基本单位,可以是节点或标记
  3. Nodes 节点:文档结构的基本单位,如段落、标题、列表项等
  4. Marks 标记:应用于文本的样式,如粗体、斜体、链接等
  5. Commands 命令:用于修改编辑器状态的操作方法

simple-editor 模版

Tiptap 的 StarterKit 仅提供基础的富文本功能逻辑,不会自带默认样式(如标题字体大小、段落首行缩进等),需要手动添加 CSS 样式来美化内容。

可以安装一个简单编辑器模版Simple template,其包括常用的开源扩展UI 组件

npx @tiptap/cli@latest add simple-editor
  1. SimpleEditor:主编辑器组件,整合所有功能
    • tiptap-templates 文件夹
  2. Toolbar:工具栏组件,提供各种编辑操作按钮
    • tiptap-ui 文件夹
  3. Extensions:预配置的扩展集合,包括基础文本格式、列表、链接等
    • tiptap-ui-primitive 文件夹
  4. Styles:配套的样式文件,提供美观的默认外观

tiptap-2-1.png

tiptap-2-2.png

操作完成后,会在项目中加入一系列的文件,包括组件、配置文件、样式文件等等:

tiptap-2-3.png

global.css全局样式文件中添加模版的样式文件:

/* tiptap */
@import "./styles/_variables.scss";
@import "./styles/_keyframe-animations.scss";

配置完成后,页面样式乱了呀,影响其他内容样式了,如下图所示:

tiptap-2-4.png

排查后,发现是暗黑模式默认宽高影响的,需要调整一下:

import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor";
import { styled } from "styled-components";

export default function Page() {
  return (
    <Container>
      <SimpleEditor />
    </Container>
  );
}

const Container = styled.div`
  width: 500px;
  margin-top: 30px;
  border: 1px solid #eee;
  .simple-editor-wrapper {
    width: 100%;
    height: 300px;
  }
`;

tiptap-2-5.png

simple-editor 改造

主题

默认主题是暗黑模式,改为明亮模式:

// 修改theme-toggle.tsx文件

// 注释掉下列代码:
React.useEffect(() => {
  const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
  const handleChange = () => {
    // setIsDarkMode(mediaQuery.matches)
  };
  mediaQuery.addEventListener("change", handleChange);
  return () => mediaQuery.removeEventListener("change", handleChange);
}, []);

// React.useEffect(() => {
//   const initialDarkMode =
//     !!document.querySelector('meta[name="color-scheme"][content="dark"]') ||
//     window.matchMedia("(prefers-color-scheme: dark)").matches;
//   setIsDarkMode(initialDarkMode);
// }, []);

然后在工具条中去掉该功能即可

工具条样式

默认工具条是横向滚动的,改为自适应

/* 修改simple-editor.css文件 */
.simple-editor-wrapper {
  width: 100%;
  height: 300px;
  border: 1px solid #eee;
  .tiptap-toolbar {
    flex-wrap: wrap;
    /* 或者直接隐藏首个 <Spacer /> 组件 */
    > div:first-of-type {
      flex: 0 !important;
    }
  }
}

调整后效果:

tiptap-2-6.png

工具条汉化

默认工具条是英文的,改为中文:

  1. 操作组件 undo-redo-button
<UndoRedoButton action="undo" />
<UndoRedoButton action="redo" />
// 修改tiptap-ui/undo-redo-button/use-undo-redo.ts文件
export const historyActionLabels: Record<UndoRedoAction, string> = {
  undo: "撤销",
  redo: "重做",
}
  1. 标题组件 heading-dropdown-menu

tiptap-2-7.png

  1. 列表组件 list-dropdown-menu

tiptap-2-8.png

  1. 引用块 blockquote-button

tiptap-2-9.png

  1. 代码块 code-block-button
  2. 文本样式 mark-button
    • 粗体 bold
    • 斜体 italic
    • 删除线 strike
    • 代码 code
    • 下划线 underline
    • 上标 superscript
    • 下标 subscript

tiptap-2-10.png

  1. 高亮组件 color-highlight-button

tiptap-2-11.png

  1. 链接组件 link-popover

tiptap-2-12.png

  1. 对齐组件 align-align-button

tiptap-2-13.png

  1. 图片组件 image-upload-button

改造前: tiptap-2-14.png

改造中: tiptap-2-15.png

改造后:

tiptap-2-16.png

Tiptap编辑器

主要介绍了编辑器的基础使用,包括依赖安装、基础配置,以及 StarterKit 基础扩展、内嵌图片 Image、编辑状态、拖拽手柄、文本样式扩展包等等

基础介绍

ProseMirror:是一款用于在网页端构建富文本编辑器的工具包,核心目标是弥合 “结构化内容编辑”(如 Markdown、XML)与 “经典所见即所得(WYSIWYG)编辑” 之间的差距 —— 既让用户能以直观的 WYSIWYG 方式编辑,又能生成干净、语义明确且符合自定义结构的文档,适用于从简单文本编辑到复杂协作系统的各类场景。

Tiptap 是基于 ProseMirror(经行业验证的网页富文本编辑器工具库)构建的无头(Headless)富文本编辑器框架,核心能力是帮助开发者打造 “完全贴合自身及用户需求” 的定制化编辑器。

其底层依赖三大核心机制实现灵活且强大的编辑功能 API:

  1. 事件(Events):监听编辑器状态变化(如内容修改、光标移动);
  2. 命令(Commands):触发编辑操作(如加粗文本、插入列表);
  3. 扩展(Extensions):扩展编辑器功能(如添加表格、AI 辅助创作)。

核心价值是 “按需构建编辑器”,既提供基础开源编辑功能,也通过云服务和扩展满足复杂场景需求,核心能力分为五大模块,覆盖从基础编辑到高级协作的全场景:Editor(编辑器)、Collaboration(协作)、Content AI(内容 AI)、Comments(评论)、Documents(文档处理)

安装

  1. @tiptap/react:Tiptap 的 React 绑定包,包含核心功能(如 useEditor 钩子、EditorContent 组件)
  2. @tiptap/pm:Tiptap 依赖的 ProseMirror 底层库,是编辑器运行的核心支撑
  3. @tiptap/starter-kit:基础扩展集合,包含段落、标题、加粗、斜体等常用功能,可快速启动项目
pnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit

基础使用

参照官方文档,完成了一个初始 Demo

import { Image } from "antd";
import { styled } from "styled-components";

import { EditorContent, EditorContext, useEditor } from "@tiptap/react";
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus"; // 导入菜单组件
import StarterKit from "@tiptap/starter-kit"; // 导入基础扩展集合
import { useMemo } from "react";
export default function Page() {
  // 1. 使用 useEditor 钩子初始化编辑器
  const editor = useEditor({
    // 配置扩展(此处使用基础扩展集合)
    extensions: [StarterKit],
    // 编辑器初始内容(HTML 格式)
    content: `
    <h1>第一章原始阶段和商周时期的江南</h1>
    <p>地方特点的青铜器。第一类典型的商代青铜器特别是那些有铭文的铜器,很可能是从中原地区传过来的;第三类具有地方特点的青铜器应是在本地铸造的;第二类青铜器有的可能铸自本地,也有的可能来自中原。</p>
    <p>这些商代铜器除了少数出自遗址和墓葬外,绝大多数出自窖藏,且多出自山顶、山腰、河岸、湖边,很有可能是当时人们祭祀山川、湖泊、日月星辰的遗物。①</p>
    <p>宁乡、湘潭等地出土的商代铜器中,有“己”分裆鼎、“癸母”提梁卤和“戈”卤等少量有铭文的铜器②;在湘潭县青山桥一铜器窖穴中也出土有商末周初的“母”爵,“母”解和“戈”解③。“母”和“戈”本是中原商代铜器中两个常见的族徽,现在江南地区也有铸这两个族徽的铜器出土,说明在商代晚期或末期,他们中的一支曾南迁到了湘中地区。</p>
    <p>湖南境内出土的西周铜器,仍以湘水流域为多,如湘潭、湘乡、浏阳、株洲、望城、衡阳、耒阳、资兴等地都有出土。器形以乐器饶、甬钟和镈为主④,还有湘潭青山桥窖藏出土的爵、解、鼎、凹字形锄等⑤。桃江连河冲出有马簋。⑥</p>
    <p>西周时期的铜饶是紧接着商代晚期的大饶发展而来。商末周初的铜铙为乳钉铙,钲的每面有18个乳钉。这些乳钉的出现可能有两个来源,其乳钉的排列和数量应来源于商代云纹铙上云纹的尾部的上翘,这有江西新干商代大墓中出土的云纹铜饶为证。乳钉的形状应是对于象纹大饶钲边乳钉的承袭。乳钉铙上的乳钉不断升高,在西周初演变为尖锥状和</p>
    `,
  });

  // 缓存 Context 值,避免不必要的重渲染
  const providerValue = useMemo(() => ({ editor }), [editor]);

  return (
    <Container>
      <div className="block-wrap">
        <div className="left">
          <Image src="/pdf/1-1.jpg" />
        </div>
        <div className="right">
          {/* 提供 EditorContext,让子组件可访问编辑器实例 */}
          <EditorContext.Provider value={providerValue}>
            {/* 2. 编辑器内容区域:渲染编辑界面 */}
            <EditorContent editor={editor} />

            {/* 3. 浮动菜单:空行光标定位时显示 */}
            <FloatingMenu editor={editor}>这是浮动菜单</FloatingMenu>

            {/* 4. 气泡菜单:选中文本时显示 */}
            <BubbleMenu editor={editor}>这是气泡菜单</BubbleMenu>
          </EditorContext.Provider>
        </div>
      </div>
    </Container>
  );
}

const Container = styled.div`
  .block-wrap {
    display: flex;
    width: 80%;
    margin: 0 auto;
    > div {
      width: 50%;
    }
  }
`;

显示效果如图 tiptap-1-1 所示:

tiptap-1-1.png

StarterKit 基础扩展集合

StarterKit 默认包含的核心扩展:文档(Document)、段落(Paragraph)、文本(Text)、标题(Heading)、加粗(Bold)、斜体(Italic)、删除线(Strike)、代码(Code)、无序列表(BulletList)、有序列表(OrderedList)等等。

具体入门套件,可以查看官网:StarterKit

在 StarterKit.configure() 中传入配置对象,通过 “扩展名称” 精准定位需修改的模块

import Strike from "@tiptap/extension-strike"; // 导入删除线扩展

new Editor({
  extensions: [
    StarterKit.configure({
      heading: { levels: [1, 2, 3] }, // 调整标题层级
    }),
  ],
});

内嵌图片 Image

具体可查看官方文档:image

# 安装扩展
pnpm install @tiptap/extension-image

使用示例如下所示:

import { Image as TiptapImage } from "@tiptap/extension-image"; // 图片扩展

const editor = useEditor({
  // 配置扩展(此处使用基础扩展集合)
  extensions: [
    StarterKit.configure({
      heading: { levels: [1, 2, 3] }, // 调整标题层级
    }),
    TiptapImage.configure({
      inline: true, // 允许行内
      allowBase64: true, // 允许 base64 格式图片
      HTMLAttributes: {
        class: "tiptap-img-inline", // 自定义样式名
      },
    }),
  ],
  content: `
    <p>宁乡、湘潭等地出土的商代铜器中,有“己<img src="/pdf/1-1-1.png" />”分裆鼎、“癸<img src="https://placehold.co/40x40/6A00F5/white" />”提梁卤和“戈”卤等少量有铭文的铜器②;在湘潭县青山桥一铜器窖穴中也出土有商末周初的“<img src="/pdf/1-1-2.png" />”爵,“<img src="/pdf/1-1-2.png" />”解和“戈”解③。“<img src="/pdf/1-1-2.png" />”和“戈”本是中原商代铜器中两个常见的族徽,现在江南地区也有铸这两个族徽的铜器出土,说明在商代晚期或末期,他们中的一支曾南迁到了湘中地区。</p>
    `,
});

配置了inline: true,设置为行内图片,但是没起作用呀。

排查发现,有个默认样式配置,将图片设置为块元素了,那只能自定义样式处理了呗。

tiptap-1-2.png

只能添加自定义样式HTMLAttributes: {class:"tiptap-img-inline"}

.tiptap-img-inline {
  display: inline; /* 强制行内显示 */
}

tiptap-1-3.png

当然,如果不想要行内显示的话,就不将 img 放到 p 标签内,而是直接放在 p 标签外,这样图片就会块级元素了。

代码展示:

tiptap-1-4.png

渲染效果展示:

tiptap-1-5.png

编辑状态控制

// 控制编辑器可编辑状态
const [isEditable, setIsEditable] = useState(false);
useEffect(() => {
  if (editor) {
    editor.setEditable(isEditable);
  }
}, [isEditable, editor]);

// 开关渲染
<Switch
  checkedChildren="开启"
  unCheckedChildren="关闭"
  checked={isEditable}
  onChange={setIsEditable}
/>;

tiptap-1-6.png

拖拽手柄控制

官方文档:drag-handle-react

文本样式集合包

文本样式集合包含:TextStyle、BackgroundColor(背景色)、Color(颜色)、FontFamily(字体)、FontSize(字号)、LineHeight(行高)

官方文档:text-style-kit

# 安装插件
pnpm install @tiptap/extension-text-style
import { TextStyleKit } from "@tiptap/extension-text-style"; // 文本样式扩展

const editor = useEditor({
  extensions: [
    StarterKit,
    // 添加文本样式功能集合,包含字体、颜色、背景色、行高、字体大小等样式控制
    TextStyleKit,
    TiptapImage,
  ],
});

ThreeJS之GUI控制器

上篇主要介绍了 three.js 的基础使用及配置,例如场景、相机、渲染器、轨道控制器、灯光等等;本篇主要介绍 GUI 控制器,方便调试模型属性,例如按钮事件、位置、颜色、线框模式等等

初始化模型

let scene; // 场景
let camera; // 相机
let renderer; // 渲染器
let controls; // 轨道控制器
const width = 300;
const height = 300;
let glbScene; // 3d加载模型
let cube; // 立方体

// 1. 初始化场景
const initScene = () => {
  scene = new THREE.Scene();
  // 设置背景色
  scene.background = new THREE.Color(0xffffff);
};

// 2. 初始化相机
const initCamera = () => {
  camera = new THREE.PerspectiveCamera(
    75, // 视角
    width / height, // 宽高比
    0.1, // 近平面
    2000 // 远平面
  );
  camera.position.z = 2; // 设置相机在 z 轴上的位置
  camera.position.y = 0.2;
  camera.lookAt(0, 0, 0); // 看向原点
};

// 3. 创建渲染器
const initRenderer = (id) => {
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(width, height); // 设置渲染器尺寸
  // renderer.setClearColor(0xffffff, 0); // 设置背景颜色 默认黑色

  // 获取渲染元素
  const container = document.getElementById(id) as HTMLElement;
  container.appendChild(renderer.domElement);
};

// 4. 添加轨道控制器(调整相机位置,即视角位置)
const initControls = () => {
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true; // 允许阻尼效果,使动画更平滑
  controls.dampingFactor = 0.05; // 阻尼系数
  controls.rotateSpeed = 0.1; // 旋转速度
  // controls.autoRotate = true; // 自动旋转
  // controls.autoRotateSpeed = 1; // 自动旋转速度
};

// 5. 动画刷新
const animate = () => {
  requestAnimationFrame(animate);
  controls.update(); // 更新控制器状态
  renderer.render(scene, camera); // 重新渲染
};

// (1)添加3d元素(立方体)
const initBox = () => {
  // import buyImg from "../../../assets/img/buy-gift.png";
  // 环境贴图
  new THREE.TextureLoader().load(buyImg, (texture) => {
    // 创建立方体(width,height,depth)
    const box = new THREE.BoxGeometry(1, 1, 1);
    // 创建材质
    const material = new THREE.MeshBasicMaterial({
      color: 0x00ff00,
      map: texture,
      side: THREE.BackSide,
    });
    // 创建网格(将物体和材质链接起来)
    cube = new THREE.Mesh(box, material);
    cube.position.set(1, 1, 0);
    // 添加到场景
    scene.add(cube);
  });
};

// (2)加载3D模型
const initGlb = (url) => {
  // 创建GLTF实例
  const loader = new GLTFLoader();
  // 加载模型
  loader.load(url, (glb) => {
    // console.log("glb.scene", glb.scene);
    glbScene = glb.scene;
    glb.scene.position.y = -0.7;
    scene.add(glb.scene);
  });
};

// (3)添加灯光
const initLight = () => {
  // 添加环境光
  const ambientLight = new THREE.AmbientLight(0xffffff, 4); // 柔和的白光
  scene.add(ambientLight);
};

// (4)添加其他
const initOther = () => {
  // 添加网格地面
  const gridHelper = new THREE.GridHelper(10, 10);
  scene.add(gridHelper);

  // 添加坐标轴
  const axesHelper = new THREE.AxesHelper(5);
  scene.add(axesHelper);
};

initScene();
initCamera();
initRenderer(item.id);
initControls();
animate();

initBox();
initGlb(item.nftFileUrl);
initLight();
initOther();

添加灯光(initLight)和其他(initOther)前的效果:

3d-4.png

添加灯光(initLight)和其他(initOther)后的效果:

3d-5.png

添加 GUI 控制

// 创建立方体
const initBox = () => {
  // 创建立方体(width,height,depth)
  const box = new THREE.BoxGeometry(1, 1, 1);
  // 创建材质
  const material = new THREE.MeshBasicMaterial({
    color: 0x00ff00,
    side: THREE.BackSide,
  });
  material.wireframe = true; // 设置为线框模式
  // 创建网格(将物体和材质链接起来)
  cube = new THREE.Mesh(box, material);
  cube.position.set(1, 1, 0);
  // 添加到场景
  scene.add(cube);
};

// 加载glb实例
const initGlb = (url) => {
  // 创建GLTF实例
  const loader = new GLTFLoader();
  // 加载模型
  loader.load(url, (glb) => {
    console.log("glb.scene", glb.scene);
    glbScene = glb.scene;
    glb.scene.position.y = -0.7;
    scene.add(glb.scene);

    // 添加GUI配置
    initGui();
  });
};

// 添加GUI配置
// import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
const initGui = () => {
  // 创建GUI
  const gui = new GUI();

  // 添加按钮(.name重命名为中文)
  const eventObj = {
    fullScreen: () => {
      // 全屏
      document.body.requestFullscreen();
    },
    exitScreen: () => {
      // 退出全屏
      document.exitFullscreen();
    },
  };
  gui.add(eventObj, "fullScreen").name("全屏");
  gui.add(eventObj, "exitScreen").name("退出全屏");

  // 控制模型位置
  console.log(glbScene);
  let folder = gui.addFolder("模型位置"); // 添加分组
  folder
    .add(glbScene.position, "x", -5, 5) // 参数和下面同等效果
    .onChange((val) => console.log("x", val)); // 变动时触发
  folder
    .add(glbScene.position, "y")
    .min(-5) // 最小值
    .max(5) // 最大值
    .step(1) // 部进
    .name("y轴") // 重命名
    .onFinishChange((val) => console.log("y", val)); // 变动结束时触发

  // 线框模式控制
  console.log("cube", cube);
  // 要有配置:material.wireframe = true,boolean值处理
  gui.add(cube.material, "wireframe").name("线框模式");

  // 颜色调整
  const colorParams = { cubeColor: "#ff0000" };
  gui
    .addColor(colorParams, "cubeColor")
    .name("立方体颜色")
    .onChange((val) => cube.material.color.set(val));
};

3d-6.png

电子书阅读器之笔记高亮(跨段处理)

文本高亮

之前介绍了电子书阅读器之笔记高亮,主要展示处理逻辑。当时,只能处理同一段内的文字,不能跨段处理。现在就介绍如何处理跨段高亮。

Selection

window.getSelection()返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。它代表页面中的文本选区,可能横跨多个元素

  • selection.toString():获取选中文本
const selection = window.getSelection();
console.log("[selection]", selection);
const selectedText = selection.toString().trim();
console.log("[选中文本]", selectedText);

highlight-1.png

Range

Range表示一个包含节点与文本节点的一部分的文档片段

  • getRangeAt:获取选区包含的指定区域(Range)的引用
const range = selection.getRangeAt(0);
console.log("[选中Range]", range);

const {
  startContainer, // 起始节点
  startOffset, // 起始节点偏移量
  endContainer, // 终止节点
  endOffset, // 终止节点偏移量
} = range;
console.log("[起始节点]", startContainer, "偏移量", startOffset);
console.log("[终止节点]", endContainer, "偏移量", endOffset);

highlight-2.png

getBoundingClientRect

const rectList = range.getClientRects();
console.log("[边界对象集]", rectList);
const rect = range.getBoundingClientRect();
console.log("[边界矩形]", rect);

highlight-3.png

highlight-4.png

选中位置定位

  1. 使用 rect 的位置,显示效果如下:
const position = { top: rect.top, left: rect.left };
console.log("[选中位置]", position);

highlight-5.png

  1. 使用 rectList 的位置,显示效果如下:
const position = { top: rectList[0].top, left: rectList[0].left };
console.log("[选中位置]", position);

highlight-6.png

我选择是这种方式,展示在选中文本第一个元素的位置。

  1. 滚动条影响

如果内容区域有滚动条,那么选中位置的定位坐标需要加上滚动条的值,即 top 需要加上滚动条的偏移量。

const position = {
  top: rectList[0].top + contentRef.current.scrollTop,
  left: rectList[0].left,
};

文本高亮

监听鼠标抬起(onMouseUp)事件,获取选中的文本,并高亮。

export default function Page() {
  // 处理鼠标抬起事件
  const handleMouseUp = () => {
    // 获取选中文本信息,包括位置信息
    const selectedRange = getSelectionPosition();
    if (selectedRange) {
      console.log(selectedRange);
      highlightText(selectedRange);
    }
  };

  return (
    <div ref={contentRef} onMouseUp={handleMouseUp} onClick={handleClick}>
      {/* 数字教材预览区 */}
      <MPreview list={list} />
      {/* 选中文本操作弹框 */}
      <PopupMenu ref={popupRef} />
      {/* 笔记编辑弹框 */}
      <PopupModify ref={modifyRef} />
    </div>
  );
}

文本节点

// 获取所有文本节点(带位置信息)
const getAllNodes = (contentRef: HTMLDivElement) => {
  if (!contentRef) return [];

  // 获取所有文本节点
  const textNodes: Node[] = [];
  const walker = document.createTreeWalker(contentRef, NodeFilter.SHOW_TEXT, {
    acceptNode: (node) =>
      node.textContent?.trim()
        ? NodeFilter.FILTER_ACCEPT
        : NodeFilter.FILTER_REJECT,
  });

  let node: Node | null = null;
  while (true) {
    node = walker.nextNode();
    if (!node) break;
    textNodes.push(node);
  }

  // 计算累积文本长度和节点位置
  let totalLength = 0;
  return textNodes.map((textNode) => {
    const text = textNode.textContent ?? "";
    const start = totalLength;
    totalLength += text.length;
    return { node: textNode, text, start, end: totalLength };
  });
};

highlight-11.png

高亮数据

const getSelectionPosition = () => {
  const selection = window.getSelection();
  // 1. 获取高亮文本
  const selectedText = selection.toString().trim();

  // 2. 获取高亮文本定位(用于弹框定位显示)
  const range = selection.getRangeAt(0);
  const rectList = range.getClientRects();
  const position = { top: rectList[0].top, left: rectList[0].left };

  // 3. 获取高亮文本位置(相对于全局文本的位置,用于高亮标色)
  let globalStart = -1;
  let globalEnd = -1;
  const nodeMap = getAllNodes();
  for (const { node, start } of nodeMap) {
    // 查找起始节点位置
    if (node === startContainer) globalStart = start + range.startOffset;
    // 查找结束节点位置
    if (node === endContainer) globalEnd = start + range.endOffset;
    // 提前退出
    if (globalStart >= 0 && globalEnd > 0) break;
  }

  if (globalStart >= 0 && globalEnd > 0) {
    console.log(`[选中范围]${globalStart}-${globalEnd}`);
    // 多行选择时,会跨元素,要找到第一个元素的id
    const startElement = getElementWithAttributes(selection.anchorNode);
    console.log("[开始元素]", startElement);

    return {
      startIndex: globalStart,
      endIndex: globalEnd,
      selectedText,
      className: "orange",
      chapterId,
      divId: startElement.id,
      top: position.top + contentRef.current.scrollTop,
      left: position.left,
    };
  }
};

highlight-7.png

高亮文本

const highlightText = (note) => {
  // 1. 先清除现有高亮
  removeHighlights(contentRef);

  // 2. 获取节点映射
  const nodeMap = getAllNodes(contentRef);

  // 3. 创建高亮范围
  const highlights: { range: Range, className: string, id: string }[] = [];

  // 4. 遍历nodeMap,处理每个文本节点
  for (const { node, text, start, end } of nodeMap) {
    const nodeStart = Math.max(0, note.startIndex - start);
    const nodeEnd = Math.min(text.length, note.endIndex - start);

    if (nodeStart >= nodeEnd) continue;
    console.log(`处理节点: "${text}" (${start}-${end})`);
    console.log(`节点高亮范围: ${nodeStart}-${nodeEnd}`);

    // 创建范围并高亮
    const range = document.createRange();
    range.setStart(node, nodeStart);
    range.setEnd(node, nodeEnd);
    highlights.push({
      range,
      className: `highlight-${note.className}`,
      id: note.divId,
    });
  }

  // 按位置从后往前高亮(避免位置偏移)
  highlights.sort((a, b) => b.range.startOffset - a.range.startOffset);
  for (const { range, className, id } of highlights) {
    // 创建高亮元素
    const span = document.createElement("span");
    span.className = className;
    // 添加笔记ID到data属性
    span.dataset.noteId = id;
    // 插入高亮内容
    span.appendChild(range.extractContents());
    range.insertNode(span);
  }
};

创建高亮元素并插入内容:

highlight-10.png

高亮前:

highlight-8.png

高亮后:

highlight-9.png

选中操作

复制

if (!note?.selectedText) return;
navigator.clipboard.writeText(note.selectedText);
toast.success("已复制到剪贴板");

报错提示:Uncaught TypeError: Cannot read properties of undefined (reading 'writeText')

报错原因:navigator.clipboard.writeText() 方法需要浏览器支持,才能正常执行。浏览器会禁用非安全域(http)的 navigator.clipboard 对象,而在 localhosthttps 下不会禁用。

❌