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.
284 lines
8.4 KiB
TypeScript
284 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
} from "react";
|
|
import { useEditor, EditorContent } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import Link from "@tiptap/extension-link";
|
|
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";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RichTextEditorProps {
|
|
defaultValue?: string;
|
|
onUpdate?: (markdown: string) => void;
|
|
placeholder?: string;
|
|
editable?: boolean;
|
|
className?: string;
|
|
debounceMs?: number;
|
|
onSubmit?: () => void;
|
|
}
|
|
|
|
interface RichTextEditorRef {
|
|
getMarkdown: () => string;
|
|
clearContent: () => void;
|
|
focus: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Submit shortcut extension (Mod+Enter)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mention extension configured for markdown serialization
|
|
// Stores as: [@Label](mention://type/id)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Link extension — always serialize as [text](url), never <url> autolinks;
|
|
// support Cmd+Click / Ctrl+Click to open in new tab.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LinkExtension = Link.configure({
|
|
openOnClick: true,
|
|
autolink: true,
|
|
HTMLAttributes: {
|
|
class: "text-primary hover:underline cursor-pointer",
|
|
},
|
|
}).extend({
|
|
addStorage() {
|
|
return {
|
|
markdown: {
|
|
serialize: {
|
|
open() {
|
|
return "[";
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
close(_state: any, mark: any) {
|
|
const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&");
|
|
const title = mark.attrs.title
|
|
? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"`
|
|
: "";
|
|
return `](${href}${title})`;
|
|
},
|
|
mixable: true,
|
|
},
|
|
parse: {},
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
const MENTION_LINK_RE =
|
|
/^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/;
|
|
|
|
const MentionExtension = Mention.configure({
|
|
HTMLAttributes: { class: "mention" },
|
|
suggestion: createMentionSuggestion(),
|
|
}).extend({
|
|
renderHTML({ node, HTMLAttributes }) {
|
|
const type = node.attrs.type ?? "member";
|
|
const label = node.attrs.label ?? node.attrs.id;
|
|
return [
|
|
"a",
|
|
{
|
|
...HTMLAttributes,
|
|
href: `mention://${type}/${node.attrs.id}`,
|
|
"data-mention-type": type,
|
|
"data-mention-id": node.attrs.id,
|
|
},
|
|
type === "issue" ? label : `@${label}`,
|
|
];
|
|
},
|
|
addAttributes() {
|
|
return {
|
|
...this.parent?.(),
|
|
type: {
|
|
default: "member",
|
|
parseHTML: (el: HTMLElement) =>
|
|
el.getAttribute("data-mention-type") ?? "member",
|
|
},
|
|
description: {
|
|
default: null,
|
|
parseHTML: (el: HTMLElement) =>
|
|
el.getAttribute("data-mention-description"),
|
|
},
|
|
};
|
|
},
|
|
|
|
// -- 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 },
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Submit shortcut extension (Mod+Enter)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createSubmitExtension(onSubmit: () => void) {
|
|
return Extension.create({
|
|
name: "submitShortcut",
|
|
addKeyboardShortcuts() {
|
|
return {
|
|
"Mod-Enter": () => {
|
|
onSubmit();
|
|
return true;
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
|
function RichTextEditor(
|
|
{
|
|
defaultValue = "",
|
|
onUpdate,
|
|
placeholder: placeholderText = "",
|
|
editable = true,
|
|
className,
|
|
debounceMs = 300,
|
|
onSubmit,
|
|
},
|
|
ref,
|
|
) {
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const onUpdateRef = useRef(onUpdate);
|
|
const onSubmitRef = useRef(onSubmit);
|
|
|
|
// Helper to get markdown from tiptap-markdown storage
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const getEditorMarkdown = (ed: any): string =>
|
|
ed?.storage?.markdown?.getMarkdown?.() ?? "";
|
|
|
|
// Keep refs in sync without recreating editor
|
|
onUpdateRef.current = onUpdate;
|
|
onSubmitRef.current = onSubmit;
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
editable,
|
|
content: defaultValue,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [1, 2, 3] },
|
|
link: false,
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder: placeholderText,
|
|
}),
|
|
LinkExtension,
|
|
Typography,
|
|
MentionExtension,
|
|
Markdown,
|
|
createSubmitExtension(() => onSubmitRef.current?.()),
|
|
],
|
|
onUpdate: ({ editor: ed }) => {
|
|
if (!onUpdateRef.current) return;
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
onUpdateRef.current?.(getEditorMarkdown(ed));
|
|
}, debounceMs);
|
|
},
|
|
editorProps: {
|
|
handleDOMEvents: {
|
|
click(_view, event) {
|
|
if (event.metaKey || event.ctrlKey) {
|
|
const link = (event.target as HTMLElement).closest("a");
|
|
const href = link?.getAttribute("href");
|
|
if (href && !href.startsWith("mention://")) {
|
|
window.open(href, "_blank", "noopener,noreferrer");
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
attributes: {
|
|
class: cn("rich-text-editor text-sm outline-none", className),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Cleanup debounce on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getMarkdown: () => getEditorMarkdown(editor),
|
|
clearContent: () => {
|
|
editor?.commands.clearContent();
|
|
},
|
|
focus: () => {
|
|
editor?.commands.focus();
|
|
},
|
|
}));
|
|
|
|
if (!editor) return null;
|
|
|
|
return <EditorContent editor={editor} />;
|
|
},
|
|
);
|
|
|
|
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };
|