Fix markdown paste handling in document editor (#2572)

Supersedes #2499.

## Thinking Path

1. **Project context**: Paperclip uses a markdown editor
(`MarkdownEditor`) for document editing. Users expect to paste
markdown-formatted text from external sources (like code editors, other
documents) and have it render correctly.

2. **Problem identification**: When users paste plain text containing
markdown syntax (e.g., `# Heading`, `- list item`), the editor was
treating it as plain text, resulting in raw markdown syntax being
displayed rather than formatted content.

3. **Root cause**: The default browser paste behavior doesn't recognize
markdown syntax in plain text. The editor needed to intercept paste
events and detect when the clipboard content looks like markdown.

4. **Solution design**: 
- Create a utility (`markdownPaste.ts`) to detect markdown patterns in
plain text
- Add a paste capture handler in `MarkdownEditor` that intercepts paste
events
- When markdown is detected, prevent default paste and use
`insertMarkdown` instead
   - Handle edge cases (code blocks, file pastes, HTML content)

## What

- Added `ui/src/lib/markdownPaste.ts`: Utility to detect markdown
patterns and normalize line endings
- Added `ui/src/lib/markdownPaste.test.ts`: Test coverage for markdown
detection
- Modified `ui/src/components/MarkdownEditor.tsx`: Added paste capture
handler to intercept and handle markdown paste

## Why

Users frequently copy markdown content from various sources (GitHub,
documentation, notes) and expect it to render correctly when pasted into
the editor. Without this fix, users see raw markdown syntax (e.g., `#
Title` instead of a formatted heading), which degrades the editing
experience.

## How to Verify

1. Open any document in Paperclip
2. Copy markdown text from an external source (e.g., `# Heading\n\n-
Item 1\n- Item 2`)
3. Paste into the editor
4. **Expected**: The content should render as formatted markdown
(heading + bullet list), not as plain text with markdown syntax

### Test Coverage

```bash
cd ui
npm test -- markdownPaste.test.ts
```

All tests should pass, including:
- Windows line ending normalization (`\r\n` → `\n`)
- Old-Mac line ending normalization (`\r` → `\n`)
- Markdown block detection (headings, lists, code fences, etc.)
- Plain text rejection (non-markdown content)

## Risks

1. **False positives**: Plain text containing markdown-like characters
(e.g., a paragraph starting with `#` as a hashtag) may be incorrectly
treated as markdown. The detection uses a heuristic that requires
block-level markdown patterns, which reduces but doesn't eliminate this
risk.

2. **Removed focus guard**: The previous implementation used
`isFocusedRef` to prevent `onChange` from firing during programmatic
`setMarkdown` calls. This guard was removed as part of refactoring. The
assumption is that MDXEditor does not fire `onChange` during
`setMarkdown`, but this should be monitored for unexpected parent update
loops.

3. **Clipboard compatibility**: The paste handler specifically looks for
`text/plain` content and ignores `text/html` (to preserve existing HTML
paste behavior). This means pasting from rich text editors that provide
both HTML and plain text will continue to use the HTML path, which may
or may not be the desired behavior.

---------

Co-authored-by: 馨冉 <xinxincui239@gmail.com>
This commit is contained in:
馨冉 2026-04-03 23:50:48 +08:00 committed by GitHub
parent 2ac40aba56
commit 728fbdd199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 161 additions and 56 deletions

View file

@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parse as parseEnvContents } from "dotenv";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
@ -540,13 +541,12 @@ describe("realizeExecutionWorkspace", () => {
path.join(expectedInstanceRoot, "secrets", "master.key"),
);
expect(envContents).not.toContain("DATABASE_URL=");
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
expect(envContents).toContain(
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
);
const envVars = parseEnvContents(envContents);
expect(envVars.PAPERCLIP_HOME).toBe(isolatedWorktreeHome);
expect(envVars.PAPERCLIP_INSTANCE_ID).toBe(expectedInstanceId);
expect(await fs.realpath(envVars.PAPERCLIP_CONFIG!)).toBe(await fs.realpath(configPath));
expect(envVars.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(envVars.PAPERCLIP_WORKTREE_NAME).toBe("PAP-885-show-worktree-banner");
process.chdir(workspace.cwd);
expect(resolvePaperclipConfigPath()).toBe(configPath);

View file

@ -1,4 +1,5 @@
import {
type ClipboardEvent,
forwardRef,
useCallback,
useEffect,
@ -32,6 +33,7 @@ import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "../lib/markdownPaste";
import { cn } from "../lib/utils";
/* ---- Mention types ---- */
@ -167,6 +169,24 @@ function detectMention(container: HTMLElement): MentionState | null {
};
}
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
return Boolean(el?.closest("pre, code"));
}
function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
if (!container) return false;
const selection = window.getSelection();
if (!selection) return false;
for (const node of [selection.anchorNode, selection.focusNode]) {
if (nodeInsideCodeLike(container, node)) return true;
}
return false;
}
function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
@ -199,11 +219,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onSubmit,
}: MarkdownEditorProps, forwardedRef) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<MDXEditorMethods | null>(null);
const ref = useRef<MDXEditorMethods>(null);
const valueRef = useRef(value);
valueRef.current = value;
const latestValueRef = useRef(value);
const latestPropValueRef = useRef(value);
const pendingExternalValueRef = useRef<string | null>(null);
const isFocusedRef = useRef(false);
const initialChildOnChangeRef = useRef(true);
/**
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
* with the same markdown. Skip notifying the parent for that echo so controlled parents that
* normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern.
*/
const echoIgnoreMarkdownRef = useRef<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
@ -237,9 +263,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState?.query, mentions]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance;
if (instance) {
const v = valueRef.current;
echoIgnoreMarkdownRef.current = v;
instance.setMarkdown(v);
latestValueRef.current = v;
}
}, []);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
},
}), []);
@ -266,10 +302,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
);
if (updated !== current) {
latestValueRef.current = updated;
editorRef.current?.setMarkdown(updated);
echoIgnoreMarkdownRef.current = updated;
ref.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
}, 100);
@ -303,29 +340,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return all;
}, [hasImageUpload]);
const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => {
editorRef.current = instance;
if (!instance) return;
const pendingValue = pendingExternalValueRef.current;
if (pendingValue !== null && pendingValue !== latestValueRef.current) {
instance.setMarkdown(pendingValue);
latestValueRef.current = pendingValue;
}
pendingExternalValueRef.current = null;
}, []);
latestPropValueRef.current = value;
useEffect(() => {
if (value !== latestValueRef.current) {
if (!editorRef.current) {
pendingExternalValueRef.current = value;
return;
if (ref.current) {
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
echoIgnoreMarkdownRef.current = value;
ref.current.setMarkdown(value);
latestValueRef.current = value;
}
editorRef.current.setMarkdown(value);
latestValueRef.current = value;
pendingExternalValueRef.current = null;
}
}, [value]);
@ -416,7 +438,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
editorRef.current?.setMarkdown(next);
echoIgnoreMarkdownRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
@ -486,6 +509,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}
const canDropImage = Boolean(imageUploadHandler);
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || !ref.current) return;
const types = new Set(Array.from(clipboard.types));
if (types.has("Files") || types.has("text/html")) return;
if (isSelectionInsideCodeLikeElement(containerRef.current)) return;
const rawText = clipboard.getData("text/plain");
if (!looksLikeMarkdownPaste(rawText)) return;
event.preventDefault();
ref.current.insertMarkdown(normalizePastedMarkdown(rawText));
}, []);
return (
<div
@ -563,35 +599,31 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
dragDepthRef.current = 0;
setIsDragOver(false);
}}
onFocusCapture={() => {
isFocusedRef.current = true;
}}
onBlurCapture={() => {
isFocusedRef.current = false;
}}
onPasteCapture={handlePasteCapture}
>
<MDXEditor
ref={handleEditorRef}
ref={setEditorRef}
markdown={value}
placeholder={placeholder}
onChange={(next) => {
const externalValue = latestPropValueRef.current;
if (!isFocusedRef.current) {
if (next === externalValue) {
latestValueRef.current = externalValue;
return;
}
latestValueRef.current = externalValue;
if (editorRef.current) {
editorRef.current.setMarkdown(externalValue);
pendingExternalValueRef.current = null;
} else {
pendingExternalValueRef.current = externalValue;
}
const echo = echoIgnoreMarkdownRef.current;
if (echo !== null && next === echo) {
echoIgnoreMarkdownRef.current = null;
latestValueRef.current = next;
return;
}
if (echo !== null) {
echoIgnoreMarkdownRef.current = null;
}
if (initialChildOnChangeRef.current) {
initialChildOnChangeRef.current = false;
if (next === "" && value !== "") {
echoIgnoreMarkdownRef.current = value;
ref.current?.setMarkdown(value);
return;
}
}
latestValueRef.current = next;
onChange(next);
}}

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "./markdownPaste";
describe("markdownPaste", () => {
it("normalizes windows line endings", () => {
expect(normalizePastedMarkdown("a\r\nb\r\n")).toBe("a\nb\n");
});
it("normalizes old mac line endings", () => {
expect(normalizePastedMarkdown("a\rb\r")).toBe("a\nb\n");
});
it("treats markdown blocks as markdown paste", () => {
expect(looksLikeMarkdownPaste("# Title\n\n- item 1\n- item 2")).toBe(true);
});
it("treats a fenced code block as markdown paste", () => {
expect(looksLikeMarkdownPaste("```\nconst x = 1;\n```")).toBe(true);
});
it("treats a tilde fence as markdown paste", () => {
expect(looksLikeMarkdownPaste("~~~\nraw\n~~~")).toBe(true);
});
it("treats a blockquote as markdown paste", () => {
expect(looksLikeMarkdownPaste("> some quoted text")).toBe(true);
});
it("treats an ordered list as markdown paste", () => {
expect(looksLikeMarkdownPaste("1. first\n2. second")).toBe(true);
});
it("treats a table row as markdown paste", () => {
expect(looksLikeMarkdownPaste("| col1 | col2 |")).toBe(true);
});
it("treats horizontal rules as markdown paste", () => {
expect(looksLikeMarkdownPaste("---")).toBe(true);
expect(looksLikeMarkdownPaste("***")).toBe(true);
expect(looksLikeMarkdownPaste("___")).toBe(true);
});
it("leaves plain multi-line text on the native paste path", () => {
expect(looksLikeMarkdownPaste("first paragraph\nsecond paragraph")).toBe(false);
});
it("leaves single-line plain text on the native paste path", () => {
expect(looksLikeMarkdownPaste("just a sentence")).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
const BLOCK_MARKER_PATTERNS = [
/^#{1,6}\s+/m,
/^>\s+/m,
/^[-*+]\s+/m,
/^\d+\.\s+/m,
/^```/m,
/^~~~/m,
/^\|.+\|$/m,
/^---$/m,
/^\*\*\*$/m,
/^___$/m,
];
export function normalizePastedMarkdown(text: string): string {
return text.replace(/\r\n?/g, "\n");
}
export function looksLikeMarkdownPaste(text: string): boolean {
const normalized = normalizePastedMarkdown(text).trim();
if (!normalized) return false;
return BLOCK_MARKER_PATTERNS.some((pattern) => pattern.test(normalized));
}