2026-02-20 13:35:15 -06:00
|
|
|
import {
|
|
|
|
|
forwardRef,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
type DragEvent,
|
|
|
|
|
} from "react";
|
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
|
|
|
import {
|
|
|
|
|
MDXEditor,
|
|
|
|
|
type MDXEditorMethods,
|
|
|
|
|
headingsPlugin,
|
|
|
|
|
imagePlugin,
|
|
|
|
|
linkDialogPlugin,
|
|
|
|
|
linkPlugin,
|
|
|
|
|
listsPlugin,
|
|
|
|
|
markdownShortcutPlugin,
|
|
|
|
|
quotePlugin,
|
|
|
|
|
thematicBreakPlugin,
|
|
|
|
|
type RealmPlugin,
|
|
|
|
|
} from "@mdxeditor/editor";
|
|
|
|
|
import { cn } from "../lib/utils";
|
|
|
|
|
|
2026-02-20 13:35:15 -06:00
|
|
|
/* ---- Mention types ---- */
|
|
|
|
|
|
|
|
|
|
export interface MentionOption {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---- Editor props ---- */
|
|
|
|
|
|
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
|
|
|
interface MarkdownEditorProps {
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
contentClassName?: string;
|
|
|
|
|
onBlur?: () => void;
|
|
|
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
|
|
|
bordered?: boolean;
|
2026-02-20 13:35:15 -06:00
|
|
|
/** List of mentionable users/agents. Enables @-mention autocomplete. */
|
|
|
|
|
mentions?: MentionOption[];
|
|
|
|
|
/** Called on Cmd/Ctrl+Enter */
|
|
|
|
|
onSubmit?: () => void;
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MarkdownEditorRef {
|
|
|
|
|
focus: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:35:15 -06:00
|
|
|
/* ---- Mention detection helpers ---- */
|
|
|
|
|
|
|
|
|
|
interface MentionState {
|
|
|
|
|
query: string;
|
|
|
|
|
top: number;
|
|
|
|
|
left: number;
|
|
|
|
|
textNode: Text;
|
|
|
|
|
atPos: number;
|
|
|
|
|
endPos: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertMention(state: MentionState, option: MentionOption) {
|
|
|
|
|
const range = document.createRange();
|
|
|
|
|
range.setStart(state.textNode, state.atPos);
|
|
|
|
|
range.setEnd(state.textNode, state.endPos);
|
|
|
|
|
|
|
|
|
|
const sel = window.getSelection();
|
|
|
|
|
sel?.removeAllRanges();
|
|
|
|
|
sel?.addRange(range);
|
|
|
|
|
|
|
|
|
|
// insertText preserves undo stack and triggers editor onChange
|
|
|
|
|
document.execCommand("insertText", false, `@${option.name} `);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---- Component ---- */
|
|
|
|
|
|
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
|
|
|
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
placeholder,
|
|
|
|
|
className,
|
|
|
|
|
contentClassName,
|
|
|
|
|
onBlur,
|
|
|
|
|
imageUploadHandler,
|
|
|
|
|
bordered = true,
|
2026-02-20 13:35:15 -06:00
|
|
|
mentions,
|
|
|
|
|
onSubmit,
|
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
|
|
|
}: 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);
|
|
|
|
|
|
2026-02-20 13:35:15 -06:00
|
|
|
// Mention state
|
|
|
|
|
const [mentionState, setMentionState] = useState<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]);
|
|
|
|
|
|
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
|
|
|
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(),
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-02-20 13:35:15 -06:00
|
|
|
// Mention detection: listen for selection changes and input events
|
|
|
|
|
const checkMention = useCallback(() => {
|
|
|
|
|
if (!mentions || mentions.length === 0 || !containerRef.current) {
|
|
|
|
|
setMentionState(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const result = detectMention(containerRef.current);
|
|
|
|
|
if (result) {
|
|
|
|
|
setMentionState(result);
|
|
|
|
|
setMentionIndex(0);
|
|
|
|
|
} else {
|
|
|
|
|
setMentionState(null);
|
|
|
|
|
}
|
|
|
|
|
}, [mentions]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!mentions || mentions.length === 0) return;
|
|
|
|
|
|
|
|
|
|
document.addEventListener("selectionchange", checkMention);
|
|
|
|
|
return () => document.removeEventListener("selectionchange", checkMention);
|
|
|
|
|
}, [checkMention, mentions]);
|
|
|
|
|
|
|
|
|
|
const selectMention = useCallback(
|
|
|
|
|
(option: MentionOption) => {
|
|
|
|
|
if (!mentionState) return;
|
|
|
|
|
insertMention(mentionState, option);
|
|
|
|
|
setMentionState(null);
|
|
|
|
|
},
|
|
|
|
|
[mentionState],
|
|
|
|
|
);
|
|
|
|
|
|
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
|
|
|
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,
|
|
|
|
|
)}
|
2026-02-20 13:35:15 -06:00
|
|
|
onKeyDownCapture={(e) => {
|
|
|
|
|
// Cmd/Ctrl+Enter to submit
|
|
|
|
|
if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onSubmit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mention keyboard navigation
|
|
|
|
|
if (mentionActive && 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;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setMentionState(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
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
|
|
|
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";
|
|
|
|
|
}}
|
2026-02-20 13:35:15 -06:00
|
|
|
onDragLeave={() => {
|
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
|
|
|
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}
|
|
|
|
|
/>
|
2026-02-20 13:35:15 -06:00
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
)}
|
|
|
|
|
|
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
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
});
|