mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
* 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>
763 lines
30 KiB
TypeScript
763 lines
30 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { createAssistantSelectionClipboardContent } from "./content.web";
|
|
|
|
const fixture = `
|
|
<div data-testid="assistant-message">
|
|
<div data-paseo-markdown-tag="p">Prefix <span data-paseo-markdown-tag="strong">bold text</span> and <span data-paseo-markdown-tag="code">inline code</span> suffix.</div>
|
|
<div data-paseo-markdown-tag="ul">
|
|
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>First bullet text</span></div></div>
|
|
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span><div><span>Second bullet text</span></div></div>
|
|
</div>
|
|
<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="ts"><span data-paseo-markdown-tag="code">const answer = true;</span></div>
|
|
</div>
|
|
`;
|
|
|
|
function mountFixture(): HTMLElement {
|
|
const host = document.createElement("div");
|
|
host.innerHTML = fixture;
|
|
document.body.append(host);
|
|
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
|
|
if (!message) {
|
|
throw new Error("Expected assistant message fixture");
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function fixtureElement<T extends Element = HTMLElement>(
|
|
root: ParentNode,
|
|
selector: string,
|
|
index = 0,
|
|
): T {
|
|
const element = root.querySelectorAll<T>(selector).item(index);
|
|
if (!element) {
|
|
throw new Error(`Expected fixture element ${selector} at index ${index}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function textNode(element: Element): Text {
|
|
const node = element.firstChild;
|
|
if (!(node instanceof Text)) {
|
|
throw new Error("Expected text node");
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function selectText(element: Element, start: number, end: number): Selection {
|
|
const node = textNode(element);
|
|
const range = document.createRange();
|
|
range.setStart(node, start);
|
|
range.setEnd(node, end);
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
throw new Error("Expected browser selection");
|
|
}
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return selection;
|
|
}
|
|
|
|
function selectRange(
|
|
startElement: Element,
|
|
startOffset: number,
|
|
endElement: Element,
|
|
endOffset: number,
|
|
): Selection {
|
|
const range = document.createRange();
|
|
range.setStart(textNode(startElement), startOffset);
|
|
range.setEnd(textNode(endElement), endOffset);
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
throw new Error("Expected browser selection");
|
|
}
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return selection;
|
|
}
|
|
|
|
function selectNodeContents(element: Element): Selection {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
throw new Error("Expected browser selection");
|
|
}
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return selection;
|
|
}
|
|
|
|
interface TranscriptMessage {
|
|
messageId: string;
|
|
blocks: string[];
|
|
}
|
|
|
|
/**
|
|
* The web transcript's shape: a scroll container holding one row per Markdown block,
|
|
* each row carrying its message id and wrapping its own assistant message element.
|
|
*/
|
|
function mountTranscript(messages: TranscriptMessage[]): HTMLElement {
|
|
const transcript = document.createElement("div");
|
|
transcript.setAttribute("data-testid", "agent-chat-scroll");
|
|
for (const { messageId, blocks } of messages) {
|
|
for (const [index, text] of blocks.entries()) {
|
|
const row = document.createElement("div");
|
|
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
|
|
row.setAttribute("data-message-id", messageId);
|
|
row.innerHTML = `<div data-testid="assistant-message"><div data-paseo-markdown-tag="p">${text}</div></div>`;
|
|
transcript.append(row);
|
|
}
|
|
}
|
|
document.body.append(transcript);
|
|
return transcript;
|
|
}
|
|
|
|
function copiedMarkdown(selection: Selection): string | null {
|
|
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
|
|
}
|
|
|
|
afterEach(() => {
|
|
window.getSelection()?.removeAllRanges();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
describe("assistant selection copy ranges", () => {
|
|
it("does not handle absent, collapsed, or non-assistant selections", () => {
|
|
expect(createAssistantSelectionClipboardContent(null)).toBeNull();
|
|
|
|
const message = mountFixture();
|
|
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
|
expect(copiedMarkdown(selectText(strong, 2, 2))).toBeNull();
|
|
|
|
const outside = document.createElement("span");
|
|
outside.textContent = "outside";
|
|
document.body.append(outside);
|
|
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
|
|
});
|
|
|
|
it("copies a selection that runs across the block rows of one message", () => {
|
|
const transcript = mountTranscript([
|
|
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
|
]);
|
|
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
|
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."] },
|
|
{ messageId: "message-2", blocks: ["Reply paragraph."] },
|
|
]);
|
|
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
|
expect(copiedMarkdown(selectRange(blocks[1]!, 0, blocks[2]!, 5))).toBeNull();
|
|
});
|
|
|
|
// A retained inactive panel renders the same agent, so the same message id can exist
|
|
// twice in the document. A group is only ever read out of one transcript, and rows
|
|
// that belong to no transcript are not a group at all.
|
|
it("does not assemble a message group from rows outside a transcript", () => {
|
|
const transcript = mountTranscript([
|
|
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
|
]);
|
|
document.body.append(...transcript.children);
|
|
transcript.remove();
|
|
const blocks = document.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
|
expect(copiedMarkdown(selectRange(blocks[0]!, 0, blocks[1]!, 17))).toBeNull();
|
|
});
|
|
|
|
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
|
|
const firstMessage = mountFixture();
|
|
const secondMessage = mountFixture();
|
|
const firstText = fixtureElement(firstMessage, '[data-paseo-markdown-tag="strong"]');
|
|
const secondText = fixtureElement(secondMessage, '[data-paseo-markdown-tag="strong"]');
|
|
expect(copiedMarkdown(selectRange(firstText, 0, secondText, 4))).toBeNull();
|
|
});
|
|
|
|
it("preserves all structure when the complete assistant message is selected", () => {
|
|
const message = mountFixture();
|
|
const content = createAssistantSelectionClipboardContent(selectNodeContents(message));
|
|
expect(content?.plainText).toBe(
|
|
[
|
|
"Prefix **bold text** and `inline code` suffix.",
|
|
"",
|
|
"- First bullet text",
|
|
"- Second bullet text",
|
|
"",
|
|
"```ts",
|
|
"const answer = true;",
|
|
"```",
|
|
].join("\n"),
|
|
);
|
|
expect(content?.html).not.toContain("<ul>");
|
|
expect(content?.html).not.toContain("<li>");
|
|
expect(content?.html).toContain("<div>- First bullet text</div>");
|
|
expect(content?.html).toContain("<div>- Second bullet text</div>");
|
|
});
|
|
|
|
it.each([
|
|
{ tag: "strong", range: [1, 5], expected: "old", forbiddenHtml: "<strong>" },
|
|
{ tag: "code", range: [2, 8], expected: "line c", forbiddenHtml: "<code>" },
|
|
])(
|
|
"copies a partial $tag range without expanding to its delimiters",
|
|
({ tag, range, expected, forbiddenHtml }) => {
|
|
const message = mountFixture();
|
|
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
|
|
const content = createAssistantSelectionClipboardContent(
|
|
selectText(element, range[0], range[1]),
|
|
);
|
|
expect(content?.plainText).toBe(expected);
|
|
expect(content?.html).not.toContain(forbiddenHtml);
|
|
},
|
|
);
|
|
|
|
it.each(["strong", "em", "s", "h2", "blockquote"])(
|
|
"copies all content selected from inside a %s element without its syntax",
|
|
(tag) => {
|
|
const message = mountFixture();
|
|
const element = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
|
element.setAttribute("data-paseo-markdown-tag", tag);
|
|
const content = createAssistantSelectionClipboardContent(
|
|
selectText(element, 0, textNode(element).length),
|
|
);
|
|
expect(content?.plainText).toBe("bold text");
|
|
expect(content?.html).not.toContain(`<${tag}>`);
|
|
},
|
|
);
|
|
|
|
it("copies complete inline code without delimiters when the selection stays inside", () => {
|
|
const message = mountFixture();
|
|
const element = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
|
|
const content = createAssistantSelectionClipboardContent(
|
|
selectText(element, 0, textNode(element).length),
|
|
);
|
|
expect(content?.plainText).toBe("inline code");
|
|
expect(content?.html).not.toContain("<code>");
|
|
});
|
|
|
|
it("keeps a complete inline node but drops formatting from a partial node at the other edge", () => {
|
|
const message = mountFixture();
|
|
const strong = fixtureElement(message, '[data-paseo-markdown-tag="strong"]');
|
|
const code = fixtureElement(message, '[data-paseo-markdown-tag="code"]');
|
|
expect(copiedMarkdown(selectRange(strong, 0, code, 6))).toBe("**bold text** and inline");
|
|
});
|
|
|
|
it("copies list-item text without inventing a bullet", () => {
|
|
const message = mountFixture();
|
|
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
|
const content = createAssistantSelectionClipboardContent(
|
|
selectText(itemText, 0, textNode(itemText).length),
|
|
);
|
|
expect(content?.plainText).toBe("First bullet text");
|
|
expect(content?.html).toContain("<p>First bullet text</p>");
|
|
expect(content?.html).not.toContain("<li>");
|
|
});
|
|
|
|
it("retains a bullet when the range includes the marker and the complete item", () => {
|
|
const message = mountFixture();
|
|
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
|
expect(copiedMarkdown(selectNodeContents(item))).toBe("- First bullet text");
|
|
});
|
|
|
|
it("retains a bullet when a drag selects from the rendered marker through the item text", () => {
|
|
const message = mountFixture();
|
|
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
|
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
|
|
|
const content = createAssistantSelectionClipboardContent(
|
|
selectRange(marker, 0, itemText, textNode(itemText).length),
|
|
);
|
|
expect(content?.plainText).toBe("- First bullet text");
|
|
expect(content?.html).toContain("<div>- First bullet text</div>");
|
|
expect(content?.html).not.toContain("<ul>");
|
|
expect(content?.html).not.toContain("<li>");
|
|
});
|
|
|
|
it("retains a bullet when a drag includes the marker and part of the item text", () => {
|
|
const message = mountFixture();
|
|
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
|
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
|
|
|
expect(copiedMarkdown(selectRange(marker, 0, itemText, 5))).toBe("- First");
|
|
});
|
|
|
|
it("does not invent a bullet when a drag starts after the rendered marker", () => {
|
|
const message = mountFixture();
|
|
const marker = fixtureElement(message, '[data-paseo-markdown-list-marker="true"]');
|
|
const itemText = fixtureElement(message, '[data-paseo-markdown-tag="li"] div span');
|
|
|
|
expect(
|
|
copiedMarkdown(
|
|
selectRange(marker, textNode(marker).length, itemText, textNode(itemText).length),
|
|
),
|
|
).toBe("First bullet text");
|
|
});
|
|
|
|
it("retains every selected marker across a partial multi-item drag", () => {
|
|
const message = mountFixture();
|
|
const markerSelector = '[data-paseo-markdown-list-marker="true"]';
|
|
const textSelector = '[data-paseo-markdown-tag="li"] div span';
|
|
const firstMarker = fixtureElement(message, markerSelector);
|
|
const secondText = fixtureElement(message, textSelector, 1);
|
|
|
|
expect(copiedMarkdown(selectRange(firstMarker, 0, secondText, 6))).toBe(
|
|
"- First bullet text\n- Second",
|
|
);
|
|
});
|
|
|
|
it("retains the original number when a marker drag starts mid-list", () => {
|
|
const message = mountFixture();
|
|
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
|
list.setAttribute("data-paseo-markdown-tag", "ol");
|
|
list.setAttribute("data-paseo-markdown-list-start", "5");
|
|
const markers = list.querySelectorAll<HTMLElement>('[data-paseo-markdown-list-marker="true"]');
|
|
markers.item(0).textContent = "5.";
|
|
markers.item(1).textContent = "6.";
|
|
const secondMarker = fixtureElement(list, '[data-paseo-markdown-list-marker="true"]', 1);
|
|
const secondText = fixtureElement(list, '[data-paseo-markdown-tag="li"] div span', 1);
|
|
|
|
expect(
|
|
copiedMarkdown(selectRange(secondMarker, 0, secondText, textNode(secondText).length)),
|
|
).toBe("6. Second bullet text");
|
|
});
|
|
|
|
it("keeps the next number when a drag crosses a code block nested in an item", () => {
|
|
const message = mountFixture();
|
|
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
|
list.outerHTML = [
|
|
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
|
'<div data-paseo-markdown-tag="li">',
|
|
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
|
"<div><div><span>Install it:</span></div>",
|
|
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
|
|
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
|
|
'<button data-paseo-markdown-ignore="true">Copy</button>',
|
|
"</div></div>",
|
|
"</div>",
|
|
'<div data-paseo-markdown-tag="li">',
|
|
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
|
|
"<div><div><span>Done.</span></div></div>",
|
|
"</div>",
|
|
"</div>",
|
|
].join("");
|
|
|
|
const content = copyAcross(message, "Install it:", "Done.");
|
|
|
|
expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done.");
|
|
expect(content?.html).toContain("<div>2. Done.</div>");
|
|
});
|
|
|
|
it("retains nested markers when a drag includes the complete outer item", () => {
|
|
const message = mountFixture();
|
|
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
|
firstItem.innerHTML = [
|
|
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
|
"<div>",
|
|
"<span>Outer text</span>",
|
|
'<div data-paseo-markdown-tag="ul">',
|
|
'<div data-paseo-markdown-tag="li">',
|
|
'<span data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">•</span>',
|
|
"<div><span>Inner text</span></div>",
|
|
"</div>",
|
|
"</div>",
|
|
"</div>",
|
|
].join("");
|
|
const outerMarker = fixtureElement(
|
|
firstItem,
|
|
':scope > [data-paseo-markdown-list-marker="true"]',
|
|
);
|
|
const innerText = fixtureElement(
|
|
firstItem,
|
|
':scope [data-paseo-markdown-tag="ul"] > [data-paseo-markdown-tag="li"] > div > span',
|
|
);
|
|
|
|
expect(copiedMarkdown(selectRange(outerMarker, 0, innerText, textNode(innerText).length))).toBe(
|
|
"- Outer text\n - Inner text",
|
|
);
|
|
});
|
|
|
|
it("preserves paragraph breaks when rich HTML flattens a loose list item", () => {
|
|
const message = mountFixture();
|
|
const item = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
|
item.replaceChildren();
|
|
item.insertAdjacentHTML(
|
|
"beforeend",
|
|
'<div data-paseo-markdown-tag="p">First paragraph</div><div data-paseo-markdown-tag="p">Second paragraph</div>',
|
|
);
|
|
|
|
const content = createAssistantSelectionClipboardContent(selectNodeContents(item));
|
|
expect(content?.plainText).toBe("- First paragraph\n\n Second paragraph");
|
|
expect(content?.html).toMatch(/<div>-\s*First paragraph\s*<br><br>Second paragraph\s*<\/div>/);
|
|
});
|
|
|
|
it("omits a partial leading bullet and retains the marker crossed before the trailing item", () => {
|
|
const message = mountFixture();
|
|
const selector = '[data-paseo-markdown-tag="li"] div span';
|
|
const first = fixtureElement(message, selector);
|
|
const second = fixtureElement(message, selector, 1);
|
|
expect(copiedMarkdown(selectRange(first, 6, second, textNode(second).length))).toBe(
|
|
"bullet text\n\n- Second bullet text",
|
|
);
|
|
});
|
|
|
|
it("copies part of a fenced code block without adding an inline or fenced delimiter", () => {
|
|
const message = mountFixture();
|
|
const blockCode = fixtureElement(
|
|
message,
|
|
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
const content = createAssistantSelectionClipboardContent(selectText(blockCode, 6, 12));
|
|
expect(content?.plainText).toBe("answer");
|
|
expect(content?.html).not.toContain("<code>");
|
|
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(
|
|
message,
|
|
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
expect(copiedMarkdown(selectText(blockCode, 0, textNode(blockCode).length))).toBe(
|
|
"const answer = true;",
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The fixture above holds a code block in one text node. The real renderer splits a
|
|
* highlighted line across one text node per syntax token and emits each newline as a
|
|
* node of its own (`highlighted-code-block.tsx`), and it puts the hover Copy button
|
|
* inside the `pre`. Line breaks and indentation only survive if the code skips
|
|
* Turndown, so those cases need a fixture shaped like the real thing.
|
|
*/
|
|
function highlightedFixture(language: string | null): string {
|
|
return [
|
|
'<div data-testid="assistant-message">',
|
|
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
|
|
highlightedCodeBlock(language),
|
|
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
|
"</div>",
|
|
].join("");
|
|
}
|
|
|
|
function highlightedCodeBlock(language: string | null): string {
|
|
const languageAttribute =
|
|
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
|
return [
|
|
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
|
'<span data-paseo-markdown-tag="code">',
|
|
"<span>const</span><span> answer</span><span> = 1;</span>",
|
|
"<span>\n</span>",
|
|
"<span> if</span><span> (answer)</span><span> {</span>",
|
|
"<span>\n</span>",
|
|
"<span> doThing();</span>",
|
|
"</span>",
|
|
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
|
"</div>",
|
|
].join("");
|
|
}
|
|
|
|
function escapeAttribute(value: string): string {
|
|
const holder = document.createElement("div");
|
|
holder.setAttribute("data-value", value);
|
|
return holder.outerHTML.slice('<div data-value="'.length, -"></div>".length);
|
|
}
|
|
|
|
function mountHighlighted(language: string | null = "typescript"): HTMLElement {
|
|
const host = document.createElement("div");
|
|
host.innerHTML = highlightedFixture(language);
|
|
document.body.append(host);
|
|
const message = host.querySelector<HTMLElement>('[data-testid="assistant-message"]');
|
|
if (!message) {
|
|
throw new Error("Expected assistant message fixture");
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/** The single text node whose content is exactly `text`. */
|
|
function tokenText(root: Node, text: string): Text {
|
|
const matches = allTextNodes(root, text);
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Expected exactly one text node "${text}", found ${matches.length}`);
|
|
}
|
|
return matches[0];
|
|
}
|
|
|
|
function allTextNodes(root: Node, text: string): Text[] {
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const matches: Text[] = [];
|
|
while (walker.nextNode()) {
|
|
if (walker.currentNode.textContent === text) {
|
|
matches.push(walker.currentNode as Text);
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
/** The standalone `\n` node that ends rendered code line `index`. */
|
|
function lineBreak(root: Node, index: number): Text {
|
|
const breaks = allTextNodes(root, "\n");
|
|
const node = breaks[index];
|
|
if (!node) {
|
|
throw new Error(`Expected a line break at index ${index}, found ${breaks.length}`);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function copyBetween(start: [Text, number], end: [Text, number]) {
|
|
const range = document.createRange();
|
|
range.setStart(start[0], start[1]);
|
|
range.setEnd(end[0], end[1]);
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
throw new Error("Expected browser selection");
|
|
}
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return createAssistantSelectionClipboardContent(selection);
|
|
}
|
|
|
|
/** Select from the start of one text node to the end of another. */
|
|
function copyAcross(root: Node, startText: string, endText: string) {
|
|
return copyBetween([tokenText(root, startText), 0], [tokenText(root, endText), endText.length]);
|
|
}
|
|
|
|
describe("assistant selection copy inside highlighted code", () => {
|
|
it("keeps the line break and the indentation of a partial multi-line selection", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyAcross(message, "const", " {");
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {");
|
|
});
|
|
|
|
it("keeps multi-line code as a block in the html half so the lines survive", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyAcross(message, "const", " {");
|
|
|
|
expect(content?.html).toBe(
|
|
'<meta charset="utf-8"><pre><code class="language-typescript">const answer = 1;\n if (answer) {</code></pre>',
|
|
);
|
|
});
|
|
|
|
it("does not escape Markdown characters inside copied code", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyBetween([tokenText(message, " = 1;"), 1], [tokenText(message, " = 1;"), 5]);
|
|
|
|
expect(content?.plainText).toBe("= 1;");
|
|
});
|
|
|
|
it("handles a common ancestor that is an element rather than a text node", () => {
|
|
const message = mountHighlighted();
|
|
|
|
// Spanning two token spans makes `commonAncestorContainer` the `code` element.
|
|
const content = copyBetween(
|
|
[tokenText(message, "const"), 1],
|
|
[tokenText(message, " answer"), 4],
|
|
);
|
|
|
|
expect(content?.plainText).toBe("onst ans");
|
|
});
|
|
|
|
it("drops a trailing line break swept in from the end of a line", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyBetween([tokenText(message, " = 1;"), 1], [lineBreak(message, 0), 1]);
|
|
|
|
expect(content?.plainText).toBe("= 1;");
|
|
});
|
|
|
|
it("drops a trailing line break together with the indentation after it", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 2]);
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;");
|
|
});
|
|
|
|
it("keeps line breaks that are not at the end of the selection", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyBetween([tokenText(message, "const"), 0], [tokenText(message, " if"), 4]);
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;\n if");
|
|
});
|
|
|
|
it("leaves the hover copy button out of the copied code", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyAcross(message, " answer", "Copy");
|
|
|
|
expect(content?.plainText).toBe(" answer = 1;\n if (answer) {\n doThing();");
|
|
expect(content?.plainText).not.toContain("Copy");
|
|
});
|
|
|
|
it("does not treat a selection inside the copy button as code", () => {
|
|
const message = mountHighlighted();
|
|
|
|
expect(copyAcross(message, "Copy", "Copy")).toBeNull();
|
|
});
|
|
|
|
it("omits the language class when the fence carries no info string", () => {
|
|
const message = mountHighlighted(null);
|
|
|
|
const content = copyAcross(message, "const", " {");
|
|
|
|
expect(content?.html).toBe(
|
|
'<meta charset="utf-8"><pre><code>const answer = 1;\n if (answer) {</code></pre>',
|
|
);
|
|
});
|
|
|
|
it("escapes a fence info string that would break out of the class attribute", () => {
|
|
const message = mountHighlighted('ts" onload="x');
|
|
|
|
const content = copyAcross(message, "const", " {");
|
|
|
|
expect(content?.html).toContain('class="language-ts" onload="x"');
|
|
expect(content?.html).not.toContain('onload="x"');
|
|
});
|
|
|
|
it("copies a fully selected multi-line region as code when the selection stays inside", () => {
|
|
const message = mountHighlighted();
|
|
const blockCode = fixtureElement(
|
|
message,
|
|
'[data-paseo-markdown-tag="pre"] [data-paseo-markdown-tag="code"]',
|
|
);
|
|
|
|
expect(copiedMarkdown(selectNodeContents(blockCode))).toBe(
|
|
"const answer = 1;\n if (answer) {\n doThing();",
|
|
);
|
|
});
|
|
|
|
// A drag that starts or ends in the gap beside a block anchors the selection at the
|
|
// edge of the neighbouring block. Nothing outside the code is highlighted or selected.
|
|
it("copies code without a fence when the selection only touches the end of the block before it", () => {
|
|
const message = mountHighlighted();
|
|
const before = tokenText(message, "Before the block.");
|
|
|
|
const content = copyBetween(
|
|
[before, before.length],
|
|
[tokenText(message, " doThing();"), 14],
|
|
);
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
|
});
|
|
|
|
it("copies code without a fence when the selection only touches the start of the block after it", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyBetween(
|
|
[tokenText(message, "const"), 0],
|
|
[tokenText(message, "After the block."), 0],
|
|
);
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
|
});
|
|
|
|
it("copies code without a fence when the selection only touches the row before the block", () => {
|
|
const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]);
|
|
const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!;
|
|
codeRow.innerHTML = highlightedCodeBlock("typescript");
|
|
const before = tokenText(transcript, "Run this:");
|
|
|
|
const content = copyBetween(
|
|
[before, before.length],
|
|
[tokenText(transcript, " doThing();"), 14],
|
|
);
|
|
|
|
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
|
|
});
|
|
|
|
it("keeps an image selected beside the code in the copy", () => {
|
|
const message = mountHighlighted();
|
|
const image = document.createElement("div");
|
|
image.setAttribute("data-paseo-markdown-tag", "p");
|
|
// 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.");
|
|
|
|
const content = copyBetween(
|
|
[before, before.length],
|
|
[tokenText(message, " doThing();"), 14],
|
|
);
|
|
|
|
expect(content?.plainText).toBe(
|
|
"\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```",
|
|
);
|
|
});
|
|
|
|
it("retains the fence when a complete code block is selected across its boundary", () => {
|
|
const message = mountHighlighted();
|
|
|
|
const content = copyAcross(message, "const", "After the block.");
|
|
|
|
expect(content?.plainText).toBe(
|
|
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
|
|
);
|
|
});
|
|
});
|