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>
150 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|