fix(editor): override renderMarkdown/parseMarkdown for mention serialization

The @tiptap/markdown extension discovers serializers via the
renderMarkdown extension field, not addStorage(). The previous
addStorage approach was silently ignored, causing mentions to serialize
as shortcode format [@ id="..." label="..."] instead of markdown links.

Now properly overrides renderMarkdown, parseMarkdown, and
markdownTokenizer to serialize mentions as [@Label](mention://type/id)
which the Markdown renderer can handle as clickable links.
This commit is contained in:
Jiang Bohan 2026-03-31 16:09:31 +08:00
parent c5b0535a3f
commit a472a0e8e0

View file

@ -14,6 +14,7 @@ import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import { Markdown } from "@tiptap/markdown";
import { Extension } from "@tiptap/core";
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
@ -82,6 +83,9 @@ const LinkExtension = Link.configure({
},
});
const MENTION_LINK_RE =
/^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/;
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
@ -105,28 +109,58 @@ const MentionExtension = Mention.configure({
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
},
description: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-description"),
},
};
},
addStorage() {
return {
markdown: {
serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) {
const type = node.attrs.type ?? "member";
const label = node.attrs.label ?? node.attrs.id;
const display = type === "issue" ? label : `@${label}`;
state.write(
`[${display}](mention://${type}/${node.attrs.id})`,
);
},
parse: {},
},
};
// -- Markdown serialization: [@Label](mention://type/id) --
renderMarkdown(node: JSONContent) {
const type = (node.attrs?.type as string) ?? "member";
const label = (node.attrs?.label as string) ?? node.attrs?.id;
const display = type === "issue" ? label : `@${label}`;
return `[${display}](mention://${type}/${node.attrs?.id})`;
},
// -- Markdown parsing: turn the link back into a mention node --
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
return h.createNode("mention", {
id: token.attributes?.id,
label: token.attributes?.label,
type: token.attributes?.type ?? "member",
});
},
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
// Find [@ or [ followed by ](mention://
const idx = src.indexOf("](mention://");
if (idx === -1) return -1;
// Walk back to find the opening [
const bracketIdx = src.lastIndexOf("[", idx);
return bracketIdx === -1 ? -1 : bracketIdx;
},
tokenize(src: string) {
const match = MENTION_LINK_RE.exec(src);
if (!match) return undefined;
const [raw, displayLabel = "", type, id] = match;
const label =
displayLabel.startsWith("@") ? displayLabel.slice(1) : displayLabel;
return {
type: "mention",
raw,
content: "",
attributes: { id, label, type },
};
},
},
});