mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 10:00:38 +09:00
Refactor onboarding wizard with ASCII art animation and expanded adapter support. Enhance markdown editor with code block, table, and CodeMirror plugins. Improve comment thread layout. Add activity charts to agent detail page. Polish metric cards, issue detail reassignment, and new issue dialog. Simplify agent detail page structure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type DragEvent,
|
|
} from "react";
|
|
import {
|
|
MDXEditor,
|
|
codeBlockPlugin,
|
|
codeMirrorPlugin,
|
|
type MDXEditorMethods,
|
|
headingsPlugin,
|
|
imagePlugin,
|
|
linkDialogPlugin,
|
|
linkPlugin,
|
|
listsPlugin,
|
|
markdownShortcutPlugin,
|
|
quotePlugin,
|
|
tablePlugin,
|
|
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;
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
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(),
|
|
tablePlugin(),
|
|
linkPlugin(),
|
|
linkDialogPlugin(),
|
|
thematicBreakPlugin(),
|
|
codeBlockPlugin(),
|
|
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
|
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 replacement = `@${option.name} `;
|
|
|
|
// 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" });
|
|
});
|
|
}
|
|
|
|
mentionStateRef.current = null;
|
|
setMentionState(null);
|
|
},
|
|
[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>
|
|
);
|
|
});
|