fix(chat): paste browser links as plain text so URLs survive (#2804)
Co-authored-by: Joao Manso <X363081@bcpcorp.net> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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"
|
||||
? '<p>Visible caption <img src="https://images.example.test/image.png"></p>'
|
||||
: "Visible caption",
|
||||
},
|
||||
const event = htmlPasteEvent(
|
||||
'<p>Visible caption <img src="https://images.example.test/image.png"></p>',
|
||||
"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(
|
||||
`<a href="${url}">Example Board Sprint 42</a>`,
|
||||
"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(
|
||||
`<A HREF="${url}">Example Board Sprint 42</A>`,
|
||||
"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(
|
||||
'<p>see <a href="https://example.com/x">the board</a> here</p>',
|
||||
"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("<b>Bold</b> and <i>italic</i> 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(
|
||||
'<p data-pm-slice="1 1 []">reference</p>',
|
||||
"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 = "<b>bold</b>\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(
|
||||
'<a href="https://example.com/y">the data-pm-slice attribute</a>',
|
||||
"the data-pm-slice attribute",
|
||||
);
|
||||
|
||||
expect(runPaste(event, pasteText)).toBe(true);
|
||||
expect(pasteText).toHaveBeenCalledWith("https://example.com/y");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (!/<a(?=[\t\n\f\r />])/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")) {
|
||||
|
||||
@@ -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", `<a href="${data.url}">${data.title}</a>`);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user