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:
parent
2ac40aba56
commit
728fbdd199
4 changed files with 161 additions and 56 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
50
ui/src/lib/markdownPaste.test.ts
Normal file
50
ui/src/lib/markdownPaste.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
ui/src/lib/markdownPaste.ts
Normal file
23
ui/src/lib/markdownPaste.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue