Files
paperclip/ui/src/components/MarkdownEditor.tsx
Forgotten a4ba4a72cd Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00

160 lines
4.5 KiB
TypeScript

import { forwardRef, 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";
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
onBlur?: () => void;
imageUploadHandler?: (file: File) => Promise<string>;
bordered?: boolean;
}
export interface MarkdownEditorRef {
focus: () => void;
}
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
value,
onChange,
placeholder,
className,
contentClassName,
onBlur,
imageUploadHandler,
bordered = true,
}: 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);
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 withImage = Boolean(imageHandler);
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]);
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,
)}
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={(evt) => {
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}
/>
{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>
);
});