"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 { 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 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 MentionExtension = Mention.configure({ HTMLAttributes: { class: "mention" }, suggestion: createMentionSuggestion(), }).extend({ renderHTML({ node, HTMLAttributes }) { return [ "a", { ...HTMLAttributes, href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`, "data-mention-type": node.attrs.type ?? "member", "data-mention-id": node.attrs.id, }, `@${node.attrs.label ?? node.attrs.id}`, ]; }, addAttributes() { return { ...this.parent?.(), type: { default: "member", parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member", }, }; }, addStorage() { return { markdown: { serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) { state.write( `[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`, ); }, parse: {}, }, }; }, }); // --------------------------------------------------------------------------- // 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( function RichTextEditor( { defaultValue = "", onUpdate, placeholder: placeholderText = "", editable = true, className, debounceMs = 300, onSubmit, }, ref, ) { const debounceRef = useRef>(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.configure({ html: false, transformPastedText: true, transformCopiedText: true, }), 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 ; }, ); export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };