The migration from tiptap-markdown to @tiptap/markdown in 38e92040
broke comment creation. The old package stored getMarkdown() on
editor.storage.markdown, but the official @tiptap/markdown extension
adds it directly to the editor instance (editor.getMarkdown()).
This caused getEditorMarkdown() to always return "", making the
submit button permanently disabled and preventing any comments.
Also fix stale submitting ref in useIssueTimeline dependency array.
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 extension
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const getEditorMarkdown = (ed: any): string =>
|
|
ed?.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 };
|