Files
PaperClipAI/ui/src/components/InlineEditor.tsx
T
DottaandPaperclip 8f2ea97480 PAP-2086 route issue description pills through MarkdownBody
Description bodies rendered via InlineEditor previously stayed in the MDX
editor even at rest, which produced plain outlined PAP-XXXX chips with no
status dot and no quicklook — the QA gap flagged on the ticket. Idle
multiline now renders MarkdownBody so issue refs pick up IssueReferencePill
+ Link (status circle + hover preview card); click or drag-enter swaps the
MDX editor back in, and we only return to the preview once the editor has
been focused at least once, blurred, and autosave has settled.

Also fix the chip typography user feedback: switch vertical-align from
middle to baseline so pills sit on the text line, and neutralize the
inline-code monospace/gray styling that leaked into backtick-wrapped chip
labels in both the MDX editor and the MarkdownBody preview.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 00:24:10 -05:00

365 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
interface InlineEditorProps {
value: string;
onSave: (value: string) => void | Promise<unknown>;
as?: "h1" | "h2" | "p" | "span";
className?: string;
placeholder?: string;
multiline?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
mentions?: MentionOption[];
nullable?: boolean;
}
/** Shared padding so display and edit modes occupy the exact same box. */
const pad = "px-1 -mx-1";
const markdownPad = "px-1";
const AUTOSAVE_DEBOUNCE_MS = 900;
export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) {
let frameId = requestAnimationFrame(() => {
frameId = requestAnimationFrame(() => {
frameId = 0;
const active = document.activeElement;
if (active instanceof Node && container.contains(active)) return;
onCommit();
});
});
return () => {
if (frameId === 0) return;
cancelAnimationFrame(frameId);
frameId = 0;
};
}
export function InlineEditor({
value,
onSave,
as: Tag = "span",
className,
placeholder = "Click to edit...",
multiline = false,
nullable = false,
imageUploadHandler,
onDropFile,
mentions,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [multilineEditing, setMultilineEditing] = useState(false);
const [multilineFocused, setMultilineFocused] = useState(false);
const [draft, setDraft] = useState(value);
const lastPropValueRef = useRef(value);
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const blurCommitFrameRef = useRef<(() => void) | null>(null);
const justEnteredEditRef = useRef(false);
const hasBeenFocusedRef = useRef(false);
const {
state: autosaveState,
markDirty,
reset,
runSave,
} = useAutosaveIndicator();
useEffect(() => {
const previousValue = lastPropValueRef.current;
lastPropValueRef.current = value;
setDraft((currentDraft) => {
if (multiline && multilineFocused && currentDraft !== previousValue) {
return currentDraft;
}
return value;
});
}, [value, multiline, multilineFocused]);
useEffect(() => {
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
if (blurCommitFrameRef.current !== null) {
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}
};
}, []);
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 (inputRef.current instanceof HTMLTextAreaElement) {
autoSize(inputRef.current);
}
}
}, [editing, autoSize]);
useEffect(() => {
if (!multilineEditing || !multiline) return;
if (!justEnteredEditRef.current) return;
justEnteredEditRef.current = false;
markdownRef.current?.focus();
}, [multilineEditing, multiline]);
// Once the editor has been focused at least once, it's blurred, and any
// autosave has settled, swap back to the MarkdownBody preview so inline
// issue refs render with status + quicklook.
useEffect(() => {
if (multilineFocused) {
hasBeenFocusedRef.current = true;
return;
}
if (!multiline || !multilineEditing) return;
if (!hasBeenFocusedRef.current) return;
if (autosaveState !== "idle") return;
hasBeenFocusedRef.current = false;
setMultilineEditing(false);
}, [multiline, multilineEditing, multilineFocused, autosaveState]);
const commit = useCallback(async (nextValue = draft) => {
const valueToSave = nextValue.trim();
const valueChanged = valueToSave !== value;
const shouldSave = nullable
? valueChanged
: Boolean(valueToSave && valueChanged);
if (shouldSave) {
await Promise.resolve(onSave(valueToSave));
} else {
setDraft(value);
}
if (!multiline) {
setEditing(false);
}
}, [draft, multiline, nullable, onSave, value]);
/** Multiline blur/submit: show autosave indicator when persisting */
const finalizeMultilineBlurOrSubmit = useCallback(() => {
const trimmed = draft.trim();
if (trimmed === value) {
reset();
void commit();
return;
}
if (!trimmed && !nullable) {
reset();
void commit();
return;
}
void runSave(() => commit());
}, [commit, draft, nullable, reset, runSave, value]);
const cancelPendingBlurCommit = useCallback(() => {
if (blurCommitFrameRef.current === null) return;
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}, []);
const scheduleBlurCommit = useCallback((container: HTMLDivElement) => {
cancelPendingBlurCommit();
blurCommitFrameRef.current = queueContainedBlurCommit(container, () => {
blurCommitFrameRef.current = null;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
finalizeMultilineBlurOrSubmit();
});
}, [cancelPendingBlurCommit, finalizeMultilineBlurOrSubmit]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
void commit();
}
if (e.key === "Escape") {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
reset();
setDraft(value);
if (multiline) {
setMultilineFocused(false);
setMultilineEditing(false);
hasBeenFocusedRef.current = false;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
} else {
setEditing(false);
}
}
}
useEffect(() => {
if (!multiline) return;
if (!multilineFocused) return;
const trimmed = draft.trim();
// Nullable: empty draft can still be a real edit (clearing); only skip debounce when unchanged or empty is invalid.
if (trimmed === value || (!trimmed && !nullable)) {
if (autosaveState !== "saved") {
reset();
}
return;
}
markDirty();
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
autosaveDebounceRef.current = setTimeout(() => {
void runSave(() => commit(trimmed));
}, AUTOSAVE_DEBOUNCE_MS);
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
};
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, nullable, reset, runSave, value]);
if (multiline) {
const hasValue = Boolean(value.trim());
const showEditor = multilineEditing || multilineFocused || !hasValue;
if (!showEditor) {
const enterEditMode = () => {
justEnteredEditRef.current = true;
setMultilineEditing(true);
};
return (
<div
className={cn(markdownPad, "rounded transition-colors hover:bg-accent/20")}
onClick={(event) => {
if (event.defaultPrevented) return;
const target = event.target as HTMLElement | null;
if (target && target.closest("a,button,[data-mention-kind],[data-radix-popper-content-wrapper]")) {
return;
}
enterEditMode();
}}
onDragEnter={() => enterEditMode()}
role="textbox"
aria-label={placeholder}
>
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
{value}
</MarkdownBody>
</div>
);
}
return (
<div
className={cn(
markdownPad,
"rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)}
onFocusCapture={(event) => {
// Ignore focus events where the active element isn't actually inside
// the wrapper (React 19 can emit a synthetic focus after a blur).
const active = document.activeElement;
if (!(active instanceof Node) || !event.currentTarget.contains(active)) return;
cancelPendingBlurCommit();
setMultilineFocused(true);
}}
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
scheduleBlurCommit(event.currentTarget);
}}
onKeyDown={handleKeyDown}
>
<MarkdownEditor
ref={markdownRef}
value={draft}
onChange={setDraft}
placeholder={placeholder}
bordered={false}
className="bg-transparent"
contentClassName={cn("paperclip-edit-in-place-content", className)}
imageUploadHandler={imageUploadHandler}
onDropFile={onDropFile}
mentions={mentions}
onSubmit={() => {
finalizeMultilineBlurOrSubmit();
}}
/>
<div className="flex min-h-4 items-center justify-end pr-1">
<span
className={cn(
"text-[11px] transition-opacity duration-150",
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
autosaveState === "idle" ? "opacity-0" : "opacity-100",
)}
>
{autosaveState === "saving"
? "Autosaving..."
: autosaveState === "saved"
? "Saved"
: autosaveState === "error"
? "Could not save"
: "Idle"}
</span>
</div>
</div>
);
}
if (editing) {
return (
<textarea
ref={inputRef}
value={draft}
rows={1}
onChange={(e) => {
setDraft(e.target.value);
autoSize(e.target);
}}
onBlur={() => {
void commit();
}}
onKeyDown={handleKeyDown}
className={cn(
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
pad,
className
)}
/>
);
}
// Use div instead of Tag when rendering markdown to avoid invalid nesting
// (e.g. <p> cannot contain the <div>/<p> elements that markdown produces)
const DisplayTag = value && multiline ? "div" : Tag;
return (
<DisplayTag
className={cn(
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
pad,
!value && "text-muted-foreground italic",
className,
)}
onClick={() => setEditing(true)}
>
{value || placeholder}
</DisplayTag>
);
}