diff --git a/apps/web/components/task/chat/tiptap-helpers.test.ts b/apps/web/components/task/chat/tiptap-helpers.test.ts index 4959d78377..5c2cc9bdfa 100644 --- a/apps/web/components/task/chat/tiptap-helpers.test.ts +++ b/apps/web/components/task/chat/tiptap-helpers.test.ts @@ -338,28 +338,130 @@ describe("handleEditorPaste", () => { expect(onImagePaste).toHaveBeenCalledWith([], "unreadable-image"); expect(preventDefault).toHaveBeenCalledOnce(); }); +}); - it("leaves rich HTML with visible text to the editor", () => { +function htmlPasteEvent(html: string, plain: string, preventDefault = vi.fn()): ClipboardEvent { + return { + clipboardData: { + files: [], + items: [], + getData: (type: string) => (type === "text/html" ? html : plain), + }, + preventDefault, + } as unknown as ClipboardEvent; +} + +function runPaste(event: ClipboardEvent, pasteText: () => void, onImagePaste = vi.fn()): boolean { + return handleEditorPaste({ pasteText } as unknown as EditorView, event, { + current: onImagePaste, + }); +} + +describe("handleEditorPaste formatting", () => { + it("strips formatting from rich HTML with visible text instead of attaching it", () => { + const pasteText = vi.fn(); const onImagePaste = vi.fn(); const preventDefault = vi.fn(); - const event = { - clipboardData: { - files: [], - items: [{ kind: "string", type: "text/html" }], - getData: (type: string) => - type === "text/html" - ? '

Visible caption

' - : "Visible caption", - }, + const event = htmlPasteEvent( + '

Visible caption

', + "Visible caption", preventDefault, - } as unknown as ClipboardEvent; + ); - const handled = handleEditorPaste({} as EditorView, event, { - current: onImagePaste, - }); - - expect(handled).toBe(false); + expect(runPaste(event, pasteText, onImagePaste)).toBe(true); expect(onImagePaste).not.toHaveBeenCalled(); + expect(pasteText).toHaveBeenCalledWith("Visible caption"); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("extracts the href when the clipboard is a single formatted link", () => { + const pasteText = vi.fn(); + const preventDefault = vi.fn(); + const url = "https://example.com/boards/sprint/42?workitem=1234"; + const event = htmlPasteEvent( + `Example Board Sprint 42`, + "Example Board Sprint 42", + preventDefault, + ); + + expect(runPaste(event, pasteText)).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(pasteText).toHaveBeenCalledWith(url); + }); + + it("extracts the href from case-insensitive HTML anchor tags", () => { + const pasteText = vi.fn(); + const url = "https://example.com/boards/sprint/42?workitem=1234"; + const event = htmlPasteEvent( + `Example Board Sprint 42`, + "Example Board Sprint 42", + ); + + expect(runPaste(event, pasteText)).toBe(true); + expect(pasteText).toHaveBeenCalledWith(url); + }); + + it("falls back to plain text when a link is embedded in other content", () => { + const pasteText = vi.fn(); + const event = htmlPasteEvent( + '

see the board here

', + "see the board here", + ); + + expect(runPaste(event, pasteText)).toBe(true); + expect(pasteText).toHaveBeenCalledWith("see the board here"); + }); + + it("strips formatting from external rich HTML, pasting only the plain text", () => { + const pasteText = vi.fn(); + const event = htmlPasteEvent("Bold and italic text", "Bold and italic text"); + + expect(runPaste(event, pasteText)).toBe(true); + expect(pasteText).toHaveBeenCalledWith("Bold and italic text"); + }); + + it("leaves internal editor copies marked with data-pm-slice to the default handler", () => { + const pasteText = vi.fn(); + const preventDefault = vi.fn(); + const event = htmlPasteEvent( + '

reference

', + "reference", + preventDefault, + ); + + expect(runPaste(event, pasteText)).toBe(false); + expect(pasteText).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled(); }); + + it("leaves plain-text-only pastes to the default handler", () => { + const pasteText = vi.fn(); + const event = htmlPasteEvent("", "plain text only"); + + expect(runPaste(event, pasteText)).toBe(false); + expect(pasteText).not.toHaveBeenCalled(); + }); +}); + +describe("handleEditorPaste paste order and ownership", () => { + it("strips external rich HTML even when its plain text contains a code fence", () => { + const pasteText = vi.fn(); + const html = "bold\n```\ncode\n```"; + const plain = "bold\n```\ncode\n```"; + const event = htmlPasteEvent(html, plain); + + expect(runPaste(event, pasteText)).toBe(true); + expect(pasteText).toHaveBeenCalledWith(plain); + }); + + it("still strips a link whose visible text merely mentions data-pm-slice", () => { + const pasteText = vi.fn(); + const event = htmlPasteEvent( + 'the data-pm-slice attribute', + "the data-pm-slice attribute", + ); + + expect(runPaste(event, pasteText)).toBe(true); + expect(pasteText).toHaveBeenCalledWith("https://example.com/y"); + }); }); diff --git a/apps/web/components/task/chat/tiptap-helpers.ts b/apps/web/components/task/chat/tiptap-helpers.ts index 92d08cac2d..d058771169 100644 --- a/apps/web/components/task/chat/tiptap-helpers.ts +++ b/apps/web/components/task/chat/tiptap-helpers.ts @@ -319,6 +319,41 @@ function insertCodeFenceNodes( } } +/** + * Rich content pasted from outside the editor should paste as plain text, the + * way Ctrl+Shift+V does. The chat composer has no link or styling marks, so the + * default HTML parse keeps a browser hyperlink's visible title and silently + * drops its URL. Copies made inside the editor (mentions, code blocks) carry + * ProseMirror's `data-pm-slice` attribute; those are left to the default handler + * so their nodes round-trip. The check matches the attribute form + * (`data-pm-slice="`) so prose that merely mentions the token is still stripped. + */ +function shouldStripPastedFormatting(clipboardData: DataTransfer): boolean { + const html = clipboardData.getData("text/html"); + if (!html) return false; + return !html.includes('data-pm-slice="'); +} + +/** + * When the clipboard payload is a single hyperlink (e.g. a link copied from a + * browser page), return its href. Such a copy's plain text is often the link's + * title rather than the URL, so preferring the href preserves the real URL. + * Returns null when the payload is not exactly one link, so the caller falls + * back to the plain text. The parsed HTML is only read for its href attribute, + * never rendered. + */ +function singleLinkHref(html: string): string | null { + if (!/])/iu.test(html)) return null; + const doc = new DOMParser().parseFromString(html, "text/html"); + const anchors = doc.querySelectorAll("a[href]"); + if (anchors.length !== 1) return null; + const href = anchors[0].getAttribute("href")?.trim(); + if (!href) return null; + const payloadText = (doc.body.textContent ?? "").replace(/\s+/gu, " ").trim(); + const linkText = (anchors[0].textContent ?? "").replace(/\s+/gu, " ").trim(); + return payloadText === linkText ? href : null; +} + export function handleEditorPaste( view: import("@tiptap/pm/view").EditorView, event: ClipboardEvent, @@ -340,8 +375,27 @@ export function handleEditorPaste( } } - // 2. Markdown code fence paste const text = clipboardData?.getData("text/plain"); + + // 2. Strip formatting from externally pasted rich content. This runs before + // the code-fence branch so external HTML is not misread as a markdown code + // block. A single copied hyperlink pastes its href (the plain text is often + // the link title, not the URL); other rich content pastes as plain text. + // Either keeps the URL that the default HTML parse would otherwise drop. + // `pasteText` fires a synthetic empty-clipboard paste, so this handler + // re-enters once, no-ops, and ProseMirror inserts the text with its own + // inline/block handling. + if (clipboardData && shouldStripPastedFormatting(clipboardData)) { + const replacement = singleLinkHref(clipboardData.getData("text/html")) ?? text; + if (replacement) { + event.preventDefault(); + view.pasteText(replacement); + return true; + } + } + + // 3. Markdown code fence paste (plain-text or internal pastes; external rich + // content was handled above). if (text && text.includes("```")) { const segments = parseCodeFences(text); if (segments.some((s) => s.type === "code")) { diff --git a/apps/web/e2e/tests/chat/paste-strip-formatting.spec.ts b/apps/web/e2e/tests/chat/paste-strip-formatting.spec.ts new file mode 100644 index 0000000000..e5d9531ce3 --- /dev/null +++ b/apps/web/e2e/tests/chat/paste-strip-formatting.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "../../fixtures/test-base"; +import { openTaskSession, waitForLatestSessionDone } from "../../helpers/session"; + +test.describe("paste strips rich text formatting", () => { + test("keeps a copied hyperlink's URL instead of its visible title", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Paste hyperlink keeps URL", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for paste-formatting task"); + const session = await openTaskSession(testPage, task.id); + await session.waitForChatIdle({ timeout: 30_000 }); + + const editor = session.activeChat().locator(".tiptap.ProseMirror").first(); + await expect(editor).toBeEditable(); + await editor.click(); + + const url = "https://example.com/boards/sprint/42?workitem=1234"; + const title = "Example Board Sprint 42"; + await editor.evaluate( + (element, data) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", data.title); + clipboardData.setData("text/html", `${data.title}`); + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData }); + element.dispatchEvent(pasteEvent); + }, + { url, title }, + ); + + await expect(editor).toContainText(url); + await expect(editor).not.toContainText(title); + }); +});