mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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>
365 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|