2026-04-02 11:51:40 -05:00
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
|
|
|
|
|
import { act } from "react";
|
|
|
|
|
import { createRoot } from "react-dom/client";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { 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: () => ({}),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-02 11:51:40 -05:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|