mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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>
This commit is contained in:
1 parent
1238130944
commit
8f2ea97480
3 files changed
+143
-12
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { act, forwardRef, useImperativeHandle, useRef, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -24,8 +24,22 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="multiline-md-preview">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { InlineEditor, queueContainedBlurCommit } from "./InlineEditor";
|
||||
|
||||
/** Enter multiline edit mode by clicking the preview surface. */
|
||||
function enterMultilineEdit(container: HTMLDivElement) {
|
||||
const preview = container.querySelector<HTMLDivElement>('[data-testid="multiline-md-preview"]');
|
||||
if (preview) {
|
||||
preview.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -139,6 +153,11 @@ describe("InlineEditor", () => {
|
||||
root.render(<InlineEditor value="hello" multiline nullable onSave={onSave} />);
|
||||
});
|
||||
|
||||
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
|
||||
act(() => {
|
||||
enterMultilineEdit(container);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
|
||||
@@ -165,6 +184,29 @@ describe("InlineEditor", () => {
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("multiline defaults to MarkdownBody preview when value is non-empty, swaps to editor on click", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<InlineEditor value="Hello world" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="multiline-md-preview"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="multiline-md-mock"]')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
enterMultilineEdit(container);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="multiline-md-mock"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="multiline-md-preview"]')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs a new multiline value while focused when the user has not edited locally", () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const root = createRoot(container);
|
||||
@@ -200,6 +242,11 @@ describe("InlineEditor", () => {
|
||||
root.render(<InlineEditor value="Original" multiline onSave={onSave} />);
|
||||
});
|
||||
|
||||
// Non-empty value renders MarkdownBody preview; click to enter edit mode.
|
||||
act(() => {
|
||||
enterMultilineEdit(container);
|
||||
});
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -52,6 +53,7 @@ export function InlineEditor({
|
||||
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);
|
||||
@@ -59,6 +61,8 @@ export function InlineEditor({
|
||||
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,
|
||||
@@ -106,12 +110,27 @@ export function InlineEditor({
|
||||
}, [editing, autoSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !multiline) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
markdownRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editing, multiline]);
|
||||
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();
|
||||
@@ -176,6 +195,8 @@ export function InlineEditor({
|
||||
setDraft(value);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
setMultilineEditing(false);
|
||||
hasBeenFocusedRef.current = false;
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
@@ -212,6 +233,36 @@ export function InlineEditor({
|
||||
}, [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(
|
||||
@@ -219,7 +270,11 @@ export function InlineEditor({
|
||||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => {
|
||||
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);
|
||||
}}
|
||||
|
||||
+33
-4
@@ -448,11 +448,23 @@
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Strip the MDXEditor's default inline-code styling from the text inside chips
|
||||
(the link label otherwise picks up a monospace font + gray tint). */
|
||||
.paperclip-mdxeditor-content a.paperclip-mention-chip,
|
||||
.paperclip-mdxeditor-content a.paperclip-mention-chip code,
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip,
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-mention-chip::before,
|
||||
a.paperclip-mention-chip::before {
|
||||
content: "";
|
||||
@@ -832,9 +844,11 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Project mention chips rendered inside MarkdownBody */
|
||||
/* Mention chips rendered inline in prose (MarkdownBody or inline anchors) */
|
||||
a.paperclip-mention-chip,
|
||||
a.paperclip-project-mention-chip {
|
||||
a.paperclip-project-mention-chip,
|
||||
span.paperclip-mention-chip,
|
||||
span.paperclip-project-mention-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -845,10 +859,25 @@ a.paperclip-project-mention-chip {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
/* Align the pill relative to the surrounding text baseline instead of its
|
||||
x-height midpoint so it sits on the text line rather than floating above. */
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* When the identifier inside a chip is backtick-wrapped in markdown, strip the
|
||||
inline-code monospace/gray styling so the pill label reads cleanly. */
|
||||
.paperclip-markdown a.paperclip-mention-chip code,
|
||||
.paperclip-markdown a.paperclip-project-mention-chip code,
|
||||
.paperclip-markdown span.paperclip-mention-chip code,
|
||||
.paperclip-markdown span.paperclip-project-mention-chip code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
|
||||
[class*="_popupContainer_"] {
|
||||
z-index: 81 !important;
|
||||
|
||||
Reference in new issue
Block a user