import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, } from "react"; import { MDXEditor, type MDXEditorMethods, headingsPlugin, imagePlugin, linkDialogPlugin, linkPlugin, listsPlugin, markdownShortcutPlugin, quotePlugin, thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ export interface MentionOption { id: string; name: string; } /* ---- Editor props ---- */ interface MarkdownEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; className?: string; contentClassName?: string; onBlur?: () => void; imageUploadHandler?: (file: File) => Promise; bordered?: boolean; /** List of mentionable users/agents. Enables @-mention autocomplete. */ mentions?: MentionOption[]; /** Called on Cmd/Ctrl+Enter */ onSubmit?: () => void; } export interface MarkdownEditorRef { focus: () => void; } /* ---- Mention detection helpers ---- */ interface MentionState { query: string; top: number; left: number; textNode: Text; atPos: number; endPos: number; } function detectMention(container: HTMLElement): MentionState | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; const range = sel.getRangeAt(0); const textNode = range.startContainer; if (textNode.nodeType !== Node.TEXT_NODE) return null; if (!container.contains(textNode)) return null; const text = textNode.textContent ?? ""; const offset = range.startOffset; // Walk backwards from cursor to find @ let atPos = -1; for (let i = offset - 1; i >= 0; i--) { const ch = text[i]; if (ch === "@") { if (i === 0 || /\s/.test(text[i - 1])) { atPos = i; } break; } if (/\s/.test(ch)) break; } if (atPos === -1) return null; const query = text.slice(atPos + 1, offset); // Get position relative to container const tempRange = document.createRange(); tempRange.setStart(textNode, atPos); tempRange.setEnd(textNode, atPos + 1); const rect = tempRange.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return { query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, textNode: textNode as Text, atPos, endPos: offset, }; } /** Replace `@` in the markdown string with `@ `. */ function applyMention(markdown: string, query: string, option: MentionOption): string { const search = `@${query}`; const replacement = `@${option.name} `; const idx = markdown.lastIndexOf(search); if (idx === -1) return markdown; return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); } /* ---- Component ---- */ export const MarkdownEditor = forwardRef(function MarkdownEditor({ value, onChange, placeholder, className, contentClassName, onBlur, imageUploadHandler, bordered = true, mentions, onSubmit, }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); const ref = useRef(null); const latestValueRef = useRef(value); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); // Mention state (ref kept in sync so callbacks always see the latest value) const [mentionState, setMentionState] = useState(null); const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const mentionActive = mentionState !== null && mentions && mentions.length > 0; const filteredMentions = useMemo(() => { if (!mentionState || !mentions) return []; const q = mentionState.query.toLowerCase(); return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); }, [mentionState?.query, mentions]); useImperativeHandle(forwardedRef, () => ({ focus: () => { ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, }), []); const plugins = useMemo(() => { const imageHandler = imageUploadHandler ? async (file: File) => { try { const src = await imageUploadHandler(file); setUploadError(null); return src; } catch (err) { const message = err instanceof Error ? err.message : "Image upload failed"; setUploadError(message); throw err; } } : undefined; const all: RealmPlugin[] = [ headingsPlugin(), listsPlugin(), quotePlugin(), linkPlugin(), linkDialogPlugin(), thematicBreakPlugin(), markdownShortcutPlugin(), ]; if (imageHandler) { all.push(imagePlugin({ imageUploadHandler: imageHandler })); } return all; }, [imageUploadHandler]); useEffect(() => { if (value !== latestValueRef.current) { ref.current?.setMarkdown(value); latestValueRef.current = value; } }, [value]); // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { if (!mentions || mentions.length === 0 || !containerRef.current) { mentionStateRef.current = null; setMentionState(null); return; } const result = detectMention(containerRef.current); mentionStateRef.current = result; if (result) { setMentionState(result); setMentionIndex(0); } else { setMentionState(null); } }, [mentions]); useEffect(() => { if (!mentions || mentions.length === 0) return; const el = containerRef.current; // Listen for input events on the container so mention detection // also fires after typing (e.g. space to dismiss). const onInput = () => requestAnimationFrame(checkMention); document.addEventListener("selectionchange", checkMention); el?.addEventListener("input", onInput, true); return () => { document.removeEventListener("selectionchange", checkMention); el?.removeEventListener("input", onInput, true); }; }, [checkMention, mentions]); const selectMention = useCallback( (option: MentionOption) => { // Read from ref to avoid stale-closure issues (selectionchange can // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; const current = latestValueRef.current; const next = applyMention(current, state.query, option); if (next !== current) { latestValueRef.current = next; ref.current?.setMarkdown(next); onChange(next); } mentionStateRef.current = null; setMentionState(null); requestAnimationFrame(() => { ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }); }, [onChange], ); function hasFilePayload(evt: DragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } const canDropImage = Boolean(imageUploadHandler); return (
{ // Cmd/Ctrl+Enter to submit if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); onSubmit(); return; } // Mention keyboard handling if (mentionActive) { // Space dismisses the popup (let the character be typed normally) if (e.key === " ") { mentionStateRef.current = null; setMentionState(null); return; } // Escape always dismisses if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); mentionStateRef.current = null; setMentionState(null); return; } // Arrow / Enter / Tab only when there are filtered results if (filteredMentions.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setMentionIndex((prev) => Math.max(prev - 1, 0)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); selectMention(filteredMentions[mentionIndex]); return; } } } }} onDragEnter={(evt) => { if (!canDropImage || !hasFilePayload(evt)) return; dragDepthRef.current += 1; setIsDragOver(true); }} onDragOver={(evt) => { if (!canDropImage || !hasFilePayload(evt)) return; evt.preventDefault(); evt.dataTransfer.dropEffect = "copy"; }} onDragLeave={() => { if (!canDropImage) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragOver(false); }} onDrop={() => { dragDepthRef.current = 0; setIsDragOver(false); }} > { latestValueRef.current = next; onChange(next); }} onBlur={() => onBlur?.()} className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} contentEditableClassName={cn( "paperclip-mdxeditor-content focus:outline-none", contentClassName, )} overlayContainer={containerRef.current} plugins={plugins} /> {/* Mention dropdown */} {mentionActive && filteredMentions.length > 0 && (
{filteredMentions.map((option, i) => ( ))}
)} {isDragOver && canDropImage && (
Drop image to upload
)} {uploadError && (

{uploadError}

)}
); });