From 728fbdd199839a0a70b825ce603b3a09456e56c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A6=A8=E5=86=89?= Date: Fri, 3 Apr 2026 23:50:48 +0800 Subject: [PATCH] Fix markdown paste handling in document editor (#2572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 馨冉 --- .../src/__tests__/workspace-runtime.test.ts | 14 +- ui/src/components/MarkdownEditor.tsx | 130 +++++++++++------- ui/src/lib/markdownPaste.test.ts | 50 +++++++ ui/src/lib/markdownPaste.ts | 23 ++++ 4 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 ui/src/lib/markdownPaste.test.ts create mode 100644 ui/src/lib/markdownPaste.ts diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 8bf008bd..f158a5e9 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index fb9b0fff..b77f926c 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -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 onSubmit, }: MarkdownEditorProps, forwardedRef) { const containerRef = useRef(null); - const editorRef = useRef(null); + const ref = useRef(null); + const valueRef = useRef(value); + valueRef.current = value; const latestValueRef = useRef(value); - const latestPropValueRef = useRef(value); - const pendingExternalValueRef = useRef(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(null); const [uploadError, setUploadError] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); @@ -237,9 +263,19 @@ export const MarkdownEditor = forwardRef 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 ); 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 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 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 } const canDropImage = Boolean(imageUploadHandler); + const handlePasteCapture = useCallback((event: ClipboardEvent) => { + 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 (
dragDepthRef.current = 0; setIsDragOver(false); }} - onFocusCapture={() => { - isFocusedRef.current = true; - }} - onBlurCapture={() => { - isFocusedRef.current = false; - }} + onPasteCapture={handlePasteCapture} > { - 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); }} diff --git a/ui/src/lib/markdownPaste.test.ts b/ui/src/lib/markdownPaste.test.ts new file mode 100644 index 00000000..9a84f903 --- /dev/null +++ b/ui/src/lib/markdownPaste.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/markdownPaste.ts b/ui/src/lib/markdownPaste.ts new file mode 100644 index 00000000..80a8886d --- /dev/null +++ b/ui/src/lib/markdownPaste.ts @@ -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)); +}