paperclip/ui/src/components/MarkdownEditor.test.tsx

190 lines
4.8 KiB
TypeScript
Raw Normal View History

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
}));
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
placeholder,
onChange,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
const [content, setContent] = React.useState(markdown);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => {},
}), []);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
const timer = window.setTimeout(() => {
setForwardedRef(forwardedRef, handle);
if (mdxEditorMockState.emitMountEmptyReset) {
setContent("");
onChange?.("");
}
}, 0);
return () => {
window.clearTimeout(timer);
setForwardedRef(forwardedRef, null);
};
}, []);
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: () => ({}),
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
fix: autoformat pasted markdown in inline editor (#2673) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The inline markdown editor (MarkdownEditor / MDXEditor) is used to edit agent instructions, issue descriptions, and other content > - When users paste agent instructions copied from terminals or consoles, extra leading whitespace is uniformly added to every line > - PR #2572 fixed markdown structure preservation on paste but did not address the leading whitespace (dedent) problem > - This pull request adds a Lexical paste normalization plugin that strips common leading whitespace and normalizes line endings before MDXEditor processes pasted content > - The benefit is that pasted content from terminals/consoles renders correctly without manual cleanup ## What Changed - **`ui/src/lib/normalize-markdown.ts`** — Pure utility that computes minimum common indentation across non-empty lines and strips it (dedent), plus CRLF → LF normalization - **`ui/src/lib/paste-normalization.ts`** — Lexical `PASTE_COMMAND` plugin at `CRITICAL` priority that intercepts plain-text pastes, normalizes the markdown, and re-dispatches cleaned content for MDXEditor to process. Skips HTML-rich pastes. - **`ui/src/components/MarkdownEditor.tsx`** — Registers the new plugin; updates PR #2572's `handlePasteCapture` to use `normalizeMarkdown()` (dedent + CRLF) instead of `normalizePastedMarkdown()` (CRLF only) for the markdown-routing path - **`ui/src/lib/paste-normalization.test.ts`** — 9 unit tests covering dedent, CRLF normalization, mixed indent, empty lines, single-line passthrough, and edge cases ## Verification - `pnpm --dir ui exec vitest run src/lib/paste-normalization.test.ts` — 9 tests pass - Manual: paste indented agent instructions from a terminal into any inline markdown editor and confirm leading whitespace is stripped ## Risks - Low risk. The plugin only activates for plain-text pastes (no HTML clipboard data). HTML/rich pastes pass through unchanged. Single-line pastes are not modified. The dedent logic is conservative — it only strips whitespace common to all non-empty lines. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-04 11:21:27 -07:00
vi.mock("../lib/paste-normalization", () => ({
pasteNormalizationPlugin: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
});
it("applies async external value updates once the editor ref becomes ready", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
await act(async () => {
root.unmount();
});
});
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
mdxEditorMockState.emitMountEmptyReset = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 180, viewportLeft: 120 },
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
),
).toEqual({
top: 372,
left: 144,
});
});
it("clamps the mention menu back into view near the viewport edges", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 260, viewportLeft: 240 },
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
),
).toEqual({
top: 12,
left: 92,
});
});
});