- Use a ref for mentionState so selectMention always reads the latest value (prevents stale-closure "blink" on click/Enter/Tab) - Add explicit space key handling to dismiss the popup immediately - Move Escape handler outside filteredMentions check so it always works - Sync mentionStateRef on all state transitions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
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<string>;
|
|
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 `@<query>` in the markdown string with `@<Name> `. */
|
|
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<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
className,
|
|
contentClassName,
|
|
onBlur,
|
|
imageUploadHandler,
|
|
bordered = true,
|
|
mentions,
|
|
onSubmit,
|
|
}: MarkdownEditorProps, forwardedRef) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const ref = useRef<MDXEditorMethods>(null);
|
|
const latestValueRef = useRef(value);
|
|
const [uploadError, setUploadError] = useState<string | null>(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<MentionState | null>(null);
|
|
const mentionStateRef = useRef<MentionState | null>(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<RealmPlugin[]>(() => {
|
|
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<HTMLDivElement>) {
|
|
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
|
}
|
|
|
|
const canDropImage = Boolean(imageUploadHandler);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"relative paperclip-mdxeditor-scope",
|
|
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
|
|
isDragOver && "ring-1 ring-primary/60 bg-accent/20",
|
|
className,
|
|
)}
|
|
onKeyDownCapture={(e) => {
|
|
// 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);
|
|
}}
|
|
>
|
|
<MDXEditor
|
|
ref={ref}
|
|
markdown={value}
|
|
placeholder={placeholder}
|
|
onChange={(next) => {
|
|
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 && (
|
|
<div
|
|
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
|
style={{ top: mentionState.top + 4, left: mentionState.left }}
|
|
>
|
|
{filteredMentions.map((option, i) => (
|
|
<button
|
|
key={option.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
|
i === mentionIndex && "bg-accent",
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault(); // prevent blur
|
|
selectMention(option);
|
|
}}
|
|
onMouseEnter={() => setMentionIndex(i)}
|
|
>
|
|
<span className="text-muted-foreground">@</span>
|
|
<span>{option.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isDragOver && canDropImage && (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute inset-1 z-40 flex items-center justify-center rounded-md border border-dashed border-primary/80 bg-primary/10 text-xs font-medium text-primary",
|
|
!bordered && "inset-0 rounded-sm",
|
|
)}
|
|
>
|
|
Drop image to upload
|
|
</div>
|
|
)}
|
|
{uploadError && (
|
|
<p className="px-3 pb-2 text-xs text-destructive">{uploadError}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|