diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 0e29db4f2dd77..2aaaabee7ad93 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -362,6 +362,26 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); +it.each(["x\r\n\r\ny", "x\r\ry"])( + "pastes %j with the same blank line as x\\n\\ny", + async (text) => { + await using screen = await mountEditor(""); + const { editor } = screen; + await act(async () => { + editor.commands.focus("end"); + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + editor.view.dom.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, cancelable: true }), + ); + }); + const paragraphs: string[] = []; + editor.state.doc.forEach((node) => paragraphs.push(node.textContent)); + expect(paragraphs).toEqual(["x", "", "y"]); + expect(serializeEditorDoc(editor.state.doc).value).toBe("x\n\ny"); + }, +); + it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; await using screen = await mountEditor(value); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 474b2f1f181c6..3761e6e8ea4ec 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -131,7 +131,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; -import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { importPastedComposerText, readPastedPlainText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -1367,7 +1367,7 @@ function ComposerPromptEditorTiptapInner( handlePaste: (view, event) => { const clipboardData = event.clipboardData; if (!clipboardData || clipboardData.files.length > 0) return false; - const pastedText = clipboardData.getData("text/plain"); + const pastedText = readPastedPlainText(clipboardData); if (!pastedText) return false; event.preventDefault(); // Paste verbatim inside fences; markdown parsing would split blocks and create unsupported chips. diff --git a/apps/web/src/components/composerInlineTokenPaste.test.ts b/apps/web/src/components/composerInlineTokenPaste.test.ts index 1ab28a5b7ff51..d201cd3adc71a 100644 --- a/apps/web/src/components/composerInlineTokenPaste.test.ts +++ b/apps/web/src/components/composerInlineTokenPaste.test.ts @@ -56,4 +56,10 @@ describe("importPastedComposerText", () => { pasted, ); }); + + it("imports CRLF and CR clipboard text with LF line breaks", () => { + expect( + importPastedComposerText(clipboard({ "text/plain": "x\r\n\r\ny\rz" }), () => new Map()), + ).toBe("x\n\ny\nz"); + }); }); diff --git a/apps/web/src/components/composerInlineTokenPaste.ts b/apps/web/src/components/composerInlineTokenPaste.ts index 0862f81ee8355..0e1a84aa71af2 100644 --- a/apps/web/src/components/composerInlineTokenPaste.ts +++ b/apps/web/src/components/composerInlineTokenPaste.ts @@ -20,6 +20,11 @@ import { readThreadShell } from "~/state/entities"; const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title; +// Matches ProseMirror's default clipboard parser, which a custom handlePaste bypasses. +export function readPastedPlainText(clipboardData: Pick): string { + return clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"); +} + /** Pasted thread links count as context too, so they become chips like a copied chip does. */ export function readPastedComposerContext( clipboardData: Pick, @@ -65,7 +70,7 @@ export function importPastedComposerText( fragment: ComposerContextClipboardFragment, ) => ReadonlyMap, ): string { - const pastedText = clipboardData.getData("text/plain"); + const pastedText = readPastedPlainText(clipboardData); const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null; const rewrittenIds = fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null;