mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Copy only the highlighted text when a selection starts below an image (#6195)
* fix(app): leave out an image when a copied selection starts below it A drag that starts in the gap below an image in an assistant reply anchored at the start of the image's rendered internals, ahead of the hidden img, so the copy included the image's Markdown although only the text after it was highlighted. A selection that starts inside an image and ends past it now starts after the image. Closes #6187 * fix(app): keep a failed image when a copy starts in its error text A failed image shows its error message as text inside the image frame. A selection that starts in that text selected part of the image, so it keeps the image's Markdown. Only a selection that starts inside the frame before any of its text starts after the image. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -168,6 +168,36 @@ async function selectAssistantImage(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Press in the gap below the image and drag to the end of `text` in the block after it. */
|
||||
async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const target = blocks.getByText(text);
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
|
||||
const end = await target.evaluate((element, selectedText) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const offset = node.textContent?.indexOf(selectedText) ?? -1;
|
||||
if (offset >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + selectedText.length);
|
||||
const box = range.getBoundingClientRect();
|
||||
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find ${selectedText}`);
|
||||
}, text);
|
||||
if (!frame) {
|
||||
throw new Error("Expected a rendered image");
|
||||
}
|
||||
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -705,6 +735,11 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
|
||||
await dragFromBelowImageTo(page, "After the");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe("After the");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
|
||||
});
|
||||
|
||||
// Chrome anchors a drag that starts in the gap below an image at the start of the
|
||||
// image's rendered internals: the background-image layer, ahead of the hidden `img`.
|
||||
// Only the text after it is highlighted.
|
||||
it("leaves out an image when the selection starts in the gap below it", () => {
|
||||
const transcript = mountTranscript([
|
||||
{
|
||||
messageId: "message-1",
|
||||
blocks: [
|
||||
"Before the image.",
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div><div style="background-image: url(blob:https://example.test/preview)"></div><img src="blob:https://example.test/preview" alt=""></div></div>',
|
||||
"After the image.",
|
||||
],
|
||||
},
|
||||
]);
|
||||
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||
const range = document.createRange();
|
||||
range.setStart(frame.firstElementChild!.firstElementChild!, 0);
|
||||
range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
expect(copiedMarkdown(selection)).toBe("After the");
|
||||
});
|
||||
|
||||
it("keeps a failed image when the selection starts in its error text", () => {
|
||||
const transcript = mountTranscript([
|
||||
{
|
||||
messageId: "message-1",
|
||||
blocks: [
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div>Image failed to load</div></div>',
|
||||
"After the image.",
|
||||
],
|
||||
},
|
||||
]);
|
||||
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||
const selection = selectRange(
|
||||
frame.firstElementChild!,
|
||||
6,
|
||||
fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1),
|
||||
9,
|
||||
);
|
||||
|
||||
expect(copiedMarkdown(selection)).toBe("\n\nAfter the");
|
||||
});
|
||||
|
||||
it("does not replace the browser clipboard for a selection reaching a second message", () => {
|
||||
const transcript = mountTranscript([
|
||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||
|
||||
@@ -24,6 +24,7 @@ const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
|
||||
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
|
||||
|
||||
const turndown = new TurndownService({
|
||||
bulletListMarker: "-",
|
||||
@@ -66,7 +67,7 @@ export function createAssistantSelectionClipboardContent(
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const range = startAfterImageFrame(selection.getRangeAt(0));
|
||||
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
||||
if (!parts?.length) {
|
||||
return null;
|
||||
@@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent(
|
||||
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag that starts in the gap below an image anchors at the start of the image's
|
||||
* rendered internals, ahead of the hidden `img`, while only the text after it is
|
||||
* highlighted. Pressing on the image itself opens it instead of starting a selection,
|
||||
* so a selection that starts inside an image, before any of its text, and ends past it
|
||||
* starts after the image. A failed image shows its error as text, and a selection
|
||||
* starting in that text keeps the image.
|
||||
*/
|
||||
function startAfterImageFrame(range: Range): Range {
|
||||
const start =
|
||||
range.startContainer instanceof Element
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const frame = start?.closest(IMAGE_FRAME_SELECTOR);
|
||||
if (!frame || frame.contains(range.endContainer)) {
|
||||
return range;
|
||||
}
|
||||
const insideFrame = range.cloneRange();
|
||||
insideFrame.setEnd(frame, frame.childNodes.length);
|
||||
if (insideFrame.toString()) {
|
||||
return range;
|
||||
}
|
||||
const afterFrame = range.cloneRange();
|
||||
afterFrame.setStartAfter(frame);
|
||||
return afterFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partly-selected code copies as the code itself, never through Turndown.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user