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>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -9,6 +11,7 @@ interface InlineEditorProps {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
@@ -21,6 +24,7 @@ export function InlineEditor({
|
||||
className,
|
||||
placeholder = "Click to edit...",
|
||||
multiline = false,
|
||||
imageUploadHandler,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
@@ -68,6 +72,35 @@ export function InlineEditor({
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -81,21 +114,6 @@ export function InlineEditor({
|
||||
onKeyDown: handleKeyDown,
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<textarea
|
||||
{...sharedProps}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full resize-none bg-accent/30 rounded outline-none",
|
||||
pad,
|
||||
"py-0.5",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
159
ui/src/components/MarkdownEditor.tsx
Normal file
159
ui/src/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import type { Project, Agent } from "@paperclip/shared";
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
@@ -98,6 +100,7 @@ export function NewIssueDialog() {
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -123,6 +126,13 @@ export function NewIssueDialog() {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDescriptionImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, "issues/drafts");
|
||||
},
|
||||
});
|
||||
|
||||
// Debounced draft saving
|
||||
const scheduleSave = useCallback(
|
||||
(draft: IssueDraft) => {
|
||||
@@ -263,20 +273,29 @@ export function NewIssueDialog() {
|
||||
placeholder="Issue title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("px-4 pb-2", expanded ? "flex-1 min-h-0" : "")}>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
||||
expanded ? "h-full" : "min-h-[60px]"
|
||||
)}
|
||||
placeholder="Add description..."
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import type { Goal } from "@paperclip/shared";
|
||||
|
||||
@@ -46,6 +48,7 @@ export function NewProjectDialog() {
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
|
||||
const { data: goals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
@@ -63,6 +66,13 @@ export function NewProjectDialog() {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadDescriptionImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setDescription("");
|
||||
@@ -145,20 +155,29 @@ export function NewProjectDialog() {
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 pb-2">
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
||||
expanded ? "min-h-[160px]" : "min-h-[48px]"
|
||||
)}
|
||||
placeholder="Add description..."
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user