mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Keep an image's alt text and source when copying a selection from an assistant reply (#6181)
* fix(app): copy an assistant image as its Markdown, with alt text Selecting across an image in an assistant reply copied the rendered img, which has no alt text and, for data and workspace images, points at a preview URL. The image frame now carries its Markdown source and alt text the same way other Markdown elements declare their copy form. Closes #6163 * fix(app): copy an image selected on its own as its Markdown A selection inside the rendered image left the image's copy attributes on the frame outside the range, so nothing was copied as Markdown. * test(app): name the image-only selection in the copy spec --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -155,6 +155,19 @@ async function selectAssistantElementFromEndOf(
|
||||
);
|
||||
}
|
||||
|
||||
/** A drag over an image alone selects it inside its rendered frame. */
|
||||
async function selectAssistantImage(page: Page): Promise<void> {
|
||||
await assistantMessageBlocks(page)
|
||||
.locator("img")
|
||||
.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNode(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -649,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-image-",
|
||||
title: "Assistant selection copy image",
|
||||
initialPrompt: "Render the image fixture.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"Before the image.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"After the image.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await allowRichClipboard(context);
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
||||
|
||||
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
`Before the image.\n\n\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -411,6 +411,20 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(content?.html).not.toContain("<pre>");
|
||||
});
|
||||
|
||||
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
|
||||
const message = mountFixture();
|
||||
const frame = document.createElement("div");
|
||||
frame.setAttribute("data-paseo-markdown-tag", "img");
|
||||
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
|
||||
frame.setAttribute("data-paseo-markdown-alt", "chart");
|
||||
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
|
||||
message.prepend(frame);
|
||||
|
||||
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies code without a fence when every character is selected inside the block", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
@@ -674,7 +688,9 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
const message = mountHighlighted();
|
||||
const image = document.createElement("div");
|
||||
image.setAttribute("data-paseo-markdown-tag", "p");
|
||||
image.innerHTML = '<div><img src="https://example.test/chart.png" alt="chart"></div>';
|
||||
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
|
||||
image.innerHTML =
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
|
||||
const before = tokenText(message, "Before the block.");
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from "@/utils/rich-clipboard";
|
||||
import {
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_SRC_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
TRAILING_CODE_LINE_BREAKS,
|
||||
@@ -293,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
// An image has no part to select, so touching it selects all of it.
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
@@ -430,7 +433,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
const visibleVoidSelector = ["br", "hr", "img"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.concat("img")
|
||||
.join(",");
|
||||
@@ -526,9 +529,13 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
continue;
|
||||
}
|
||||
const semanticElement = document.createElement(tagName);
|
||||
if (tagName !== "br") {
|
||||
if (tagName !== "br" && tagName !== "img") {
|
||||
semanticElement.append(...element.childNodes);
|
||||
}
|
||||
if (tagName === "img") {
|
||||
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
|
||||
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
|
||||
}
|
||||
if (tagName === "ol") {
|
||||
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
||||
if (start) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
|
||||
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
|
||||
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
|
||||
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
|
||||
|
||||
/**
|
||||
* Trailing line breaks, with any indentation that followed the last one.
|
||||
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image copies as the Markdown it came from. The rendered `img` points at a preview
|
||||
* URL for data and workspace images, and carries no alt text of its own.
|
||||
*/
|
||||
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
|
||||
return {
|
||||
paseoMarkdownTag: "img",
|
||||
paseoMarkdownSrc: source,
|
||||
...(alt ? { paseoMarkdownAlt: alt } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
|
||||
const alignment =
|
||||
typeof style === "string"
|
||||
|
||||
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
markdownCopyDataSet,
|
||||
markdownCopyImageDataSet,
|
||||
markdownCopyOrderedListDataSet,
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
|
||||
],
|
||||
[containerStyle, imageSizeStyle],
|
||||
);
|
||||
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={frameStyle} dataSet={copyDataSet}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
|
||||
Reference in New Issue
Block a user