633 lines
20 KiB
TypeScript
633 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";
|
|
import { normalizeMarkdownArtifacts } from "../lib/markdown";
|
|
|
|
/* ---- 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(normalizeMarkdownArtifacts(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(() => {
|
|
const normalizedValue = normalizeMarkdownArtifacts(value);
|
|
if (normalizedValue !== latestValueRef.current) {
|
|
ref.current?.setMarkdown(normalizedValue);
|
|
latestValueRef.current = normalizedValue;
|
|
}
|
|
}, [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={normalizeMarkdownArtifacts(value)}
|
|
placeholder={placeholder}
|
|
onChange={(next) => {
|
|
const normalizedNext = normalizeMarkdownArtifacts(next);
|
|
latestValueRef.current = normalizedNext;
|
|
if (normalizedNext !== next) {
|
|
ref.current?.setMarkdown(normalizedNext);
|
|
}
|
|
onChange(normalizedNext);
|
|
}}
|
|
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,
|
|
)}
|
|
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)}
|
|
>
|
|
{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>
|
|
);
|
|
});
|