import { type ClipboardEvent, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type DragEvent, } from "react"; import { createPortal } from "react-dom"; import { CodeMirrorEditor, MDXEditor, codeBlockPlugin, codeMirrorPlugin, type CodeBlockEditorDescriptor, type MDXEditorMethods, headingsPlugin, imagePlugin, linkDialogPlugin, linkPlugin, listsPlugin, markdownShortcutPlugin, quotePlugin, tablePlugin, thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { Boxes } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; import { looksLikeMarkdownPaste } from "../lib/markdownPaste"; import { normalizeMarkdown } from "../lib/normalize-markdown"; import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { cn } from "../lib/utils"; import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext"; /* ---- Mention types ---- */ export interface MentionOption { id: string; name: string; kind?: "agent" | "project"; agentId?: string; agentIcon?: string | null; projectId?: string; projectColor?: string | null; } /* ---- Editor props ---- */ interface MarkdownEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; className?: string; contentClassName?: string; onBlur?: () => void; imageUploadHandler?: (file: File) => Promise; /** Called when a non-image file is dropped onto the editor (e.g. .zip). */ onDropFile?: (file: File) => Promise; bordered?: boolean; /** List of mentionable entities. Enables @-mention autocomplete. */ mentions?: MentionOption[]; /** Called on Cmd/Ctrl+Enter */ onSubmit?: () => void; } export interface MarkdownEditorRef { focus: () => void; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function isSafeMarkdownLinkUrl(url: string): boolean { const trimmed = url.trim(); if (!trimmed) return true; return !/^(javascript|data|vbscript):/i.test(trimmed); } /* ---- Mention detection helpers ---- */ interface MentionState { trigger: "mention" | "skill"; marker: "@" | "/"; query: string; top: number; left: number; /** Viewport-relative coords for portal positioning */ viewportTop: number; viewportLeft: number; textNode: Text; atPos: number; endPos: number; } type AutocompleteOption = MentionOption | SkillCommandOption; interface MentionMenuViewport { offsetLeft: number; offsetTop: number; width: number; height: number; } interface MentionMenuSize { width: number; height: number; } const MENTION_MENU_WIDTH = 188; const MENTION_MENU_HEIGHT = 208; const MENTION_MENU_PADDING = 8; const MENTION_MENU_ROW_HEIGHT = 34; const MENTION_MENU_CHROME_HEIGHT = 8; const CODE_BLOCK_LANGUAGES: Record = { txt: "Text", md: "Markdown", js: "JavaScript", jsx: "JavaScript (JSX)", ts: "TypeScript", tsx: "TypeScript (TSX)", json: "JSON", bash: "Bash", sh: "Shell", python: "Python", go: "Go", rust: "Rust", sql: "SQL", html: "HTML", css: "CSS", yaml: "YAML", yml: "YAML", }; const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = { // Keep this lower than codeMirrorPlugin's descriptor priority so known languages // still use the standard matching path; this catches malformed/unknown fences. priority: 0, match: () => true, Editor: CodeMirrorEditor, }; export function findMentionMatch( text: string, offset: number, ): Pick | null { let atPos = -1; let trigger: MentionState["trigger"] | null = null; let marker: MentionState["marker"] | null = null; for (let i = offset - 1; i >= 0; i--) { const ch = text[i]; if (ch === "@" || ch === "/") { if (i === 0 || /\s/.test(text[i - 1])) { atPos = i; trigger = ch === "@" ? "mention" : "skill"; marker = ch; } break; } if (ch === "\n" || ch === "\r") break; } if (atPos === -1) return null; const query = text.slice(atPos + 1, offset); if (trigger === "skill" && /\s/.test(query)) return null; return { trigger: trigger ?? "mention", marker: marker ?? "@", query, atPos, endPos: offset, }; } 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; const match = findMentionMatch(text, offset); if (!match) return null; // Get position relative to container const tempRange = document.createRange(); tempRange.setStart(textNode, match.atPos); tempRange.setEnd(textNode, match.atPos + 1); const rect = tempRange.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return { trigger: match.trigger, marker: match.marker, query: match.query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, viewportTop: rect.bottom, viewportLeft: rect.left, textNode: textNode as Text, atPos: match.atPos, endPos: match.endPos, }; } function getMentionMenuViewport(): MentionMenuViewport { const viewport = window.visualViewport; if (viewport) { return { offsetLeft: viewport.offsetLeft, offsetTop: viewport.offsetTop, width: viewport.width, height: viewport.height, }; } return { offsetLeft: 0, offsetTop: 0, width: window.innerWidth, height: window.innerHeight, }; } export function computeMentionMenuPosition( anchor: Pick, viewport: MentionMenuViewport, menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT }, ) { const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width; const minTop = viewport.offsetTop + MENTION_MENU_PADDING; const maxTop = viewport.offsetTop + viewport.height - menuSize.height; return { top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)), }; } function getMentionMenuSize(optionCount: number): MentionMenuSize { const visibleRows = Math.max(1, Math.min(optionCount, 8)); return { width: MENTION_MENU_WIDTH, height: Math.min( MENTION_MENU_HEIGHT, visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT, ), }; } function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; const el = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement; return Boolean(el?.closest("pre, code")); } function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { if (!container) return false; const selection = window.getSelection(); if (!selection) return false; for (const node of [selection.anchorNode, selection.focusNode]) { if (nodeInsideCodeLike(container, node)) return true; } return false; } function mentionMarkdown(option: MentionOption): string { if (option.kind === "project" && option.projectId) { return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; } const agentId = option.agentId ?? option.id.replace(/^agent:/, ""); return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } function skillMarkdown(option: SkillCommandOption): string { return `[/${option.slug}](${option.href}) `; } function autocompleteMarkdown(option: AutocompleteOption): string { return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); } /** Replace the active autocomplete token in the markdown string with the selected token. */ function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string { const search = `${state.marker}${state.query}`; const replacement = autocompleteMarkdown(option); 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, onDropFile, bordered = true, mentions, onSubmit, }: MarkdownEditorProps, forwardedRef) { const { slashCommands } = useEditorAutocomplete(); const containerRef = useRef(null); const ref = useRef(null); const valueRef = useRef(value); valueRef.current = value; const latestValueRef = useRef(value); const initialChildOnChangeRef = useRef(true); /** * After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange` * with the same markdown. Skip notifying the parent for that echo so controlled parents that * normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern. */ const echoIgnoreMarkdownRef = useRef(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); // Stable ref for imageUploadHandler so plugins don't recreate on every render const imageUploadHandlerRef = useRef(imageUploadHandler); imageUploadHandlerRef.current = imageUploadHandler; // 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 && ( (mentionState.trigger === "mention" && Boolean(mentions?.length)) || (mentionState.trigger === "skill" && slashCommands.length > 0) ); const mentionOptionByKey = useMemo(() => { const map = new Map(); for (const mention of mentions ?? []) { if (mention.kind === "agent") { const agentId = mention.agentId ?? mention.id.replace(/^agent:/, ""); map.set(`agent:${agentId}`, mention); } if (mention.kind === "project" && mention.projectId) { map.set(`project:${mention.projectId}`, mention); } } return map; }, [mentions]); const filteredMentions = useMemo(() => { if (!mentionState) return []; const q = mentionState.query.trim().toLowerCase(); if (mentionState.trigger === "skill") { return slashCommands .filter((command) => { if (!q) return true; return command.aliases.some((alias) => alias.toLowerCase().includes(q)); }) .slice(0, 8); } if (!mentions) return []; return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); }, [mentionState, mentions, slashCommands]); const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { ref.current = instance; if (instance) { const v = valueRef.current; echoIgnoreMarkdownRef.current = v; instance.setMarkdown(v); latestValueRef.current = v; } }, []); useImperativeHandle(forwardedRef, () => ({ focus: () => { ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }, }), []); // Whether the image plugin should be included (boolean is stable across renders // as long as the handler presence doesn't toggle) const hasImageUpload = Boolean(imageUploadHandler); const plugins = useMemo(() => { const imageHandler = hasImageUpload ? async (file: File) => { const handler = imageUploadHandlerRef.current; if (!handler) throw new Error("No image upload handler"); try { const src = await handler(file); setUploadError(null); // After MDXEditor inserts the image, ensure two newlines follow it // so the cursor isn't stuck right next to the image. setTimeout(() => { const current = latestValueRef.current; const escapedSrc = escapeRegExp(src); const updated = current.replace( new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"), "$1\n\n", ); if (updated !== current) { latestValueRef.current = updated; echoIgnoreMarkdownRef.current = updated; ref.current?.setMarkdown(updated); onChange(updated); requestAnimationFrame(() => { ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); }); } }, 100); 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(), tablePlugin(), linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }), linkDialogPlugin(), mentionDeletionPlugin(), pasteNormalizationPlugin(), thematicBreakPlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: "txt", codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR], }), codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }), markdownShortcutPlugin(), ]; if (imageHandler) { all.push(imagePlugin({ imageUploadHandler: imageHandler })); } return all; }, [hasImageUpload]); useEffect(() => { if (value !== latestValueRef.current) { if (ref.current) { // Pair with onChange echo suppression (echoIgnoreMarkdownRef). echoIgnoreMarkdownRef.current = value; ref.current.setMarkdown(value); latestValueRef.current = value; } } }, [value]); const decorateProjectMentions = useCallback(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; const links = editable.querySelectorAll("a"); for (const node of links) { const link = node as HTMLAnchorElement; const parsed = parseMentionChipHref(link.getAttribute("href") ?? ""); if (!parsed) { clearMentionChipDecoration(link); continue; } if (parsed.kind === "project") { const option = mentionOptionByKey.get(`project:${parsed.projectId}`); applyMentionChipDecoration(link, { ...parsed, color: parsed.color ?? option?.projectColor ?? null, }); continue; } if (parsed.kind === "skill") { applyMentionChipDecoration(link, parsed); continue; } const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); applyMentionChipDecoration(link, { ...parsed, icon: parsed.icon ?? option?.agentIcon ?? null, }); } }, [mentionOptionByKey]); // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) { mentionStateRef.current = null; setMentionState(null); return; } const result = detectMention(containerRef.current); if ( result && result.trigger === "mention" && (!mentions || mentions.length === 0) ) { mentionStateRef.current = null; setMentionState(null); return; } if ( result && result.trigger === "skill" && slashCommands.length === 0 ) { mentionStateRef.current = null; setMentionState(null); return; } mentionStateRef.current = result; if (result) { setMentionState(result); setMentionIndex(0); } else { setMentionState(null); } }, [mentions, slashCommands.length]); useEffect(() => { if ((!mentions || mentions.length === 0) && slashCommands.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, slashCommands.length]); useEffect(() => { if (!mentionActive) return; const updatePosition = () => requestAnimationFrame(checkMention); const viewport = window.visualViewport; viewport?.addEventListener("resize", updatePosition); viewport?.addEventListener("scroll", updatePosition); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { viewport?.removeEventListener("resize", updatePosition); viewport?.removeEventListener("scroll", updatePosition); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [checkMention, mentionActive]); useEffect(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; decorateProjectMentions(); const observer = new MutationObserver(() => { decorateProjectMentions(); }); observer.observe(editable, { subtree: true, childList: true, characterData: true, }); return () => observer.disconnect(); }, [decorateProjectMentions, value]); const selectMention = useCallback( (option: AutocompleteOption) => { // 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, option); if (next !== current) { latestValueRef.current = next; echoIgnoreMarkdownRef.current = next; ref.current?.setMarkdown(next); onChange(next); } requestAnimationFrame(() => { requestAnimationFrame(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!(editable instanceof HTMLElement)) return; decorateProjectMentions(); editable.focus(); const mentionHref = option.kind === "skill" ? option.href : option.kind === "project" && option.projectId ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) : buildAgentMentionHref( option.agentId ?? option.id.replace(/^agent:/, ""), option.agentIcon ?? null, ); const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`; const matchingMentions = Array.from(editable.querySelectorAll("a")) .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) .filter((link) => { const href = link.getAttribute("href") ?? ""; return href === mentionHref && link.textContent === expectedLabel; }); const containerRect = containerRef.current?.getBoundingClientRect(); const target = matchingMentions.sort((a, b) => { const rectA = a.getBoundingClientRect(); const rectB = b.getBoundingClientRect(); const leftA = containerRect ? rectA.left - containerRect.left : rectA.left; const topA = containerRect ? rectA.top - containerRect.top : rectA.top; const leftB = containerRect ? rectB.left - containerRect.left : rectB.left; const topB = containerRect ? rectB.top - containerRect.top : rectB.top; const distA = Math.hypot(leftA - state.left, topA - state.top); const distB = Math.hypot(leftB - state.left, topB - state.top); return distA - distB; })[0] ?? null; if (!target) return; const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); const nextSibling = target.nextSibling; if (nextSibling?.nodeType === Node.TEXT_NODE) { const text = nextSibling.textContent ?? ""; if (text.startsWith(" ")) { range.setStart(nextSibling, 1); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; } } range.setStartAfter(target); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); }); }); mentionStateRef.current = null; setMentionState(null); }, [decorateProjectMentions, onChange], ); function hasFilePayload(evt: DragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } const canDropImage = Boolean(imageUploadHandler); const canDropFile = Boolean(imageUploadHandler || onDropFile); const handlePasteCapture = useCallback((event: ClipboardEvent) => { const clipboard = event.clipboardData; if (!clipboard || !ref.current) return; const types = new Set(Array.from(clipboard.types)); if (types.has("Files") || types.has("text/html")) return; if (isSelectionInsideCodeLikeElement(containerRef.current)) return; const rawText = clipboard.getData("text/plain"); if (!looksLikeMarkdownPaste(rawText)) return; event.preventDefault(); ref.current.insertMarkdown(normalizeMarkdown(rawText)); }, []); const mentionMenuPosition = mentionState ? computeMentionMenuPosition( mentionState, getMentionMenuViewport(), getMentionMenuSize(filteredMentions.length), ) : null; 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) { if (e.key === " " && mentionStateRef.current?.trigger === "skill") { 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 (!canDropFile || !hasFilePayload(evt)) return; dragDepthRef.current += 1; setIsDragOver(true); }} onDragOver={(evt) => { if (!canDropFile || !hasFilePayload(evt)) return; evt.preventDefault(); evt.dataTransfer.dropEffect = "copy"; }} onDragLeave={() => { if (!canDropFile) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragOver(false); }} onDrop={(evt) => { dragDepthRef.current = 0; setIsDragOver(false); if (!onDropFile) return; const files = evt.dataTransfer?.files; if (!files || files.length === 0) return; const allFiles = Array.from(files); const nonImageFiles = allFiles.filter( (f) => !f.type.startsWith("image/"), ); if (nonImageFiles.length === 0) return; // If all dropped files are non-image, prevent default so MDXEditor // doesn't try to handle them. If mixed, let images flow through to // the image plugin and only handle the non-image files ourselves. if (nonImageFiles.length === allFiles.length) { evt.preventDefault(); evt.stopPropagation(); } for (const file of nonImageFiles) { void onDropFile(file); } }} onPasteCapture={handlePasteCapture} > { const echo = echoIgnoreMarkdownRef.current; if (echo !== null && next === echo) { echoIgnoreMarkdownRef.current = null; latestValueRef.current = next; return; } if (echo !== null) { echoIgnoreMarkdownRef.current = null; } if (initialChildOnChangeRef.current) { initialChildOnChangeRef.current = false; if (next === "" && value !== "") { echoIgnoreMarkdownRef.current = value; ref.current?.setMarkdown(value); return; } } latestValueRef.current = next; onChange(next); }} onBlur={() => onBlur?.()} className={cn("paperclip-mdxeditor", !bordered && "paperclip-mdxeditor--borderless")} contentEditableClassName={cn( "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", contentClassName, )} additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]} plugins={plugins} /> {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} {mentionActive && filteredMentions.length > 0 && createPortal(
{filteredMentions.map((option, i) => ( ))}
, document.body, )} {isDragOver && canDropFile && (
Drop {onDropFile ? "file" : "image"} to upload
)} {uploadError && (

{uploadError}

)}
); });