Files
paperclip/ui/src/components/MarkdownEditor.tsx
2026-03-10 21:01:47 -05:00

626 lines
20 KiB
TypeScript

import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent,
} from "react";
import {
CodeMirrorEditor,
MDXEditor,
codeBlockPlugin,
codeMirrorPlugin,
type CodeBlockEditorDescriptor,
type MDXEditorMethods,
headingsPlugin,
imagePlugin,
linkDialogPlugin,
linkPlugin,
listsPlugin,
markdownShortcutPlugin,
quotePlugin,
tablePlugin,
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
/* ---- Mention types ---- */
export interface MentionOption {
id: string;
name: string;
kind?: "agent" | "project";
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<string>;
bordered?: boolean;
/** List of mentionable entities. 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;
}
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
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,
};
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,
};
}
function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
}
return `@${option.name} `;
}
/** Replace `@<query>` in the markdown string with the selected mention token. */
function applyMention(markdown: string, query: string, option: MentionOption): string {
const search = `@${query}`;
const replacement = mentionMarkdown(option);
const idx = markdown.lastIndexOf(search);
if (idx === -1) return markdown;
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const trimmed = hex.trim();
const match = /^#([0-9a-f]{6})$/i.exec(trimmed);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function mentionChipStyle(color: string | null): CSSProperties | undefined {
if (!color) return undefined;
const rgb = hexToRgb(color);
if (!rgb) return undefined;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
const textColor = luminance > 0.55 ? "#111827" : "#f8fafc";
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: textColor,
};
}
/* ---- 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);
// 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<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
const projectColorById = useMemo(() => {
const map = new Map<string, string | null>();
for (const mention of mentions ?? []) {
if (mention.kind === "project" && mention.projectId) {
map.set(mention.projectId, mention.projectColor ?? null);
}
}
return map;
}, [mentions]);
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" });
},
}), []);
// 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<RealmPlugin[]>(() => {
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);
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(),
linkDialogPlugin(),
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) {
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 = parseProjectMentionHref(link.getAttribute("href") ?? "");
if (!parsed) {
if (link.dataset.projectMention === "true") {
link.dataset.projectMention = "false";
link.classList.remove("paperclip-project-mention-chip");
link.removeAttribute("contenteditable");
link.style.removeProperty("border-color");
link.style.removeProperty("background-color");
link.style.removeProperty("color");
}
continue;
}
const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null;
link.dataset.projectMention = "true";
link.classList.add("paperclip-project-mention-chip");
link.setAttribute("contenteditable", "false");
const style = mentionChipStyle(color);
if (style) {
link.style.borderColor = style.borderColor ?? "";
link.style.backgroundColor = style.backgroundColor ?? "";
link.style.color = style.color ?? "";
}
}
}, [projectColorById]);
// 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]);
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: 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;
if (option.kind === "project" && option.projectId) {
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
decorateProjectMentions();
});
mentionStateRef.current = null;
setMentionState(null);
return;
}
const replacement = mentionMarkdown(option);
// Replace @query directly via DOM selection so the cursor naturally
// lands after the inserted text. Lexical picks up the change through
// its normal input-event handling.
const sel = window.getSelection();
if (sel && state.textNode.isConnected) {
const range = document.createRange();
range.setStart(state.textNode, state.atPos);
range.setEnd(state.textNode, state.endPos);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, replacement);
// After Lexical reconciles the DOM, the cursor position set by
// execCommand may be lost. Explicitly reposition it after the
// inserted mention text.
const cursorTarget = state.atPos + replacement.length;
requestAnimationFrame(() => {
const newSel = window.getSelection();
if (!newSel) return;
// Try the original text node first (it may still be valid)
if (state.textNode.isConnected) {
const len = state.textNode.textContent?.length ?? 0;
if (cursorTarget <= len) {
const r = document.createRange();
r.setStart(state.textNode, cursorTarget);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
// Fallback: search for the replacement in text nodes
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent ?? "";
const idx = text.indexOf(replacement);
if (idx !== -1) {
const pos = idx + replacement.length;
if (pos <= text.length) {
const r = document.createRange();
r.setStart(node, pos);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
}
});
} else {
// Fallback: full markdown replacement when DOM node is stale
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
requestAnimationFrame(() => {
decorateProjectMentions();
});
mentionStateRef.current = null;
setMentionState(null);
},
[decorateProjectMentions, 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 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
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)}
>
{option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : (
<span className="text-muted-foreground">@</span>
)}
<span>{option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</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>
);
});