Files
paperclip/ui/src/components/InlineEditor.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

150 lines
3.7 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from "react";
import Markdown from "react-markdown";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { MarkdownEditor } from "./MarkdownEditor";
interface InlineEditorProps {
value: string;
onSave: (value: string) => void;
as?: "h1" | "h2" | "p" | "span";
className?: string;
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
}
/** Shared padding so display and edit modes occupy the exact same box. */
const pad = "px-1 -mx-1";
export function InlineEditor({
value,
onSave,
as: Tag = "span",
className,
placeholder = "Click to edit...",
multiline = false,
imageUploadHandler,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value);
}, [value]);
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, []);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
if (multiline && inputRef.current instanceof HTMLTextAreaElement) {
autoSize(inputRef.current);
}
}
}, [editing, multiline, autoSize]);
function commit() {
const trimmed = draft.trim();
if (trimmed && trimmed !== value) {
onSave(trimmed);
} else {
setDraft(value);
}
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
commit();
}
if (e.key === "Escape") {
setDraft(value);
setEditing(false);
}
}
if (editing) {
if (multiline) {
return (
<div className={cn("space-y-2", pad)}>
<MarkdownEditor
value={draft}
onChange={setDraft}
placeholder={placeholder}
contentClassName={className}
imageUploadHandler={imageUploadHandler}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setDraft(value);
setEditing(false);
}}
>
Cancel
</Button>
<Button size="sm" onClick={commit}>
Save
</Button>
</div>
</div>
);
}
const sharedProps = {
ref: inputRef as any,
value: draft,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setDraft(e.target.value);
if (multiline && e.target instanceof HTMLTextAreaElement) {
autoSize(e.target);
}
},
onBlur: commit,
onKeyDown: handleKeyDown,
};
return (
<input
type="text"
{...sharedProps}
className={cn(
"w-full bg-transparent rounded outline-none",
pad,
className
)}
/>
);
}
return (
<Tag
className={cn(
"cursor-pointer rounded hover:bg-accent/50 transition-colors",
pad,
!value && "text-muted-foreground italic",
className
)}
onClick={() => setEditing(true)}
>
{value && multiline ? (
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
<Markdown>{value}</Markdown>
</div>
) : (
value || placeholder
)}
</Tag>
);
}