Keep copied assistant selections within their visible boundaries (#2930)
* fix(app): respect assistant copy selection boundaries Selection serialization reintroduced semantic ancestors even when the user selected only their inner text, causing list markers and code delimiters to expand beyond the visible range. Preserve semantics only for complete selected nodes, and keep rich list payloads compatible with text composers that prefer HTML. * fix(app): preserve loose list copy breaks
This commit is contained in:
@@ -199,12 +199,14 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
|
||||
const clipboard = await readRichClipboard(page);
|
||||
expect(clipboard.plainText).toBe(EXPECTED_WHOLE_SELECTION_MARKDOWN);
|
||||
expect(clipboard.html).toContain("<ul>");
|
||||
expect(clipboard.html).not.toContain("<ul>");
|
||||
expect(clipboard.html).not.toContain("<li>");
|
||||
expect(clipboard.html).toContain("<div>- ");
|
||||
expect(clipboard.html).toContain(
|
||||
'<strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
expect(clipboard.html).toContain("<code>apply_patch</code>");
|
||||
expect(clipboard.html).toContain('<ol start="5">');
|
||||
expect(clipboard.html).toContain("<div>5. Fifth item</div>");
|
||||
expect(clipboard.html).toContain("A hard break<br>");
|
||||
expect(clipboard.html).toContain(
|
||||
'<a href="http://www.example.com/">www.example.com</a> stays plain Markdown text.',
|
||||
@@ -238,9 +240,27 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await copySelection(page);
|
||||
|
||||
const partialClipboard = await readRichClipboard(page);
|
||||
expect(partialClipboard.plainText).toBe("- **[First](https://example.com/issues/1)**");
|
||||
expect(partialClipboard.html).toContain(
|
||||
'<li><strong><a href="https://example.com/issues/1">First</a></strong></li>',
|
||||
expect(partialClipboard.plainText).toBe("First");
|
||||
expect(partialClipboard.html).toContain("<p>First</p>");
|
||||
|
||||
await selectAssistantTextRange(page, "Direct matches:", "repeated sandbox setup.");
|
||||
await copySelection(page);
|
||||
|
||||
const paragraphAndListClipboard = await readRichClipboard(page);
|
||||
expect(paragraphAndListClipboard.plainText).toBe(
|
||||
[
|
||||
"Direct matches:",
|
||||
"",
|
||||
"Formatted **strong prose**, _emphasized prose_, and ~~struck prose~~.",
|
||||
"",
|
||||
"- **[First issue](https://example.com/issues/1)**: exact `apply_patch` failure.",
|
||||
"- [Second issue](https://example.com/issues/2): repeated sandbox setup.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<ul>");
|
||||
expect(paragraphAndListClipboard.html).not.toContain("<li>");
|
||||
expect(paragraphAndListClipboard.html).toContain(
|
||||
'<div>- <strong><a href="https://example.com/issues/1">First issue</a></strong>',
|
||||
);
|
||||
|
||||
await selectAssistantText(page, "docs");
|
||||
@@ -276,22 +296,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await copySelection(page);
|
||||
|
||||
const midListClipboard = await readRichClipboard(page);
|
||||
expect(midListClipboard.plainText).toBe("7. Seventh item\n8. Eighth item");
|
||||
expect(midListClipboard.html).toContain('<ol start="7">');
|
||||
expect(midListClipboard.plainText).toBe("Seventh item\n\n8. Eighth item");
|
||||
expect(midListClipboard.html).toContain("<div>8. Eighth item</div>");
|
||||
|
||||
await selectAssistantTextRange(page, "Inner eight", "Inner nine");
|
||||
await copySelection(page);
|
||||
|
||||
const nestedListClipboard = await readRichClipboard(page);
|
||||
expect(nestedListClipboard.plainText).toBe("3. 8. Inner eight\n 9. Inner nine");
|
||||
expect(nestedListClipboard.html).toContain('<ol start="8">');
|
||||
expect(nestedListClipboard.plainText).toBe("Inner eight\n\n9. Inner nine");
|
||||
expect(nestedListClipboard.html).toContain("<div>9. Inner nine</div>");
|
||||
|
||||
await selectAssistantTextRange(page, "Seventh item", "Nested list:");
|
||||
await copySelection(page);
|
||||
|
||||
const crossBlockClipboard = await readRichClipboard(page);
|
||||
expect(crossBlockClipboard.plainText).toBe("7. Seventh item\n8. Eighth item\n\nNested list:");
|
||||
expect(crossBlockClipboard.html).toContain('<ol start="7">');
|
||||
expect(crossBlockClipboard.plainText).toBe("Seventh item\n\n8. Eighth item\n\nNested list:");
|
||||
expect(crossBlockClipboard.html).toContain("<div>8. Eighth item</div>");
|
||||
|
||||
await selectAssistantText(page, "Current");
|
||||
await copySelection(page);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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">•</span><div><span>First bullet text</span></div></div>
|
||||
<div data-paseo-markdown-tag="li"><span data-paseo-markdown-ignore="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;
|
||||
}
|
||||
|
||||
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("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([
|
||||
{ tag: "strong", expected: "**bold text**" },
|
||||
{ tag: "code", expected: "`inline code`" },
|
||||
])("retains $tag delimiters when its complete contents are selected", ({ tag, expected }) => {
|
||||
const message = mountFixture();
|
||||
const element = fixtureElement(message, `[data-paseo-markdown-tag="${tag}"]`);
|
||||
const content = createAssistantSelectionClipboardContent(
|
||||
selectText(element, 0, textNode(element).length),
|
||||
);
|
||||
expect(content?.plainText).toBe(expected);
|
||||
expect(content?.html).toContain(`<${tag}>`);
|
||||
});
|
||||
|
||||
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("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("retains the fence and language when the complete code block is selected", () => {
|
||||
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(
|
||||
"```ts\nconst answer = true;\n```",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -66,65 +66,219 @@ export function createAssistantSelectionClipboardContent(
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const selected = wrapSelectionWithAncestors(range, startMessage);
|
||||
normalizeOrderedListStarts(selected, range, startMessage);
|
||||
const selected = cloneMarkdownSelection(range, startMessage);
|
||||
container.append(selected);
|
||||
restoreMarkdownElements(container);
|
||||
|
||||
const markdown = turndown.turndown(container.innerHTML).trim();
|
||||
return markdown ? createMarkdownClipboardContent(markdown) : null;
|
||||
if (!markdown) {
|
||||
return null;
|
||||
}
|
||||
const content = createMarkdownClipboardContent(markdown);
|
||||
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
||||
}
|
||||
|
||||
function wrapSelectionWithAncestors(range: Range, message: Element): Node {
|
||||
let selected: Node = range.cloneContents();
|
||||
function flattenClipboardListMarkup(html: string): string {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
|
||||
const lists = Array.from(container.querySelectorAll("ul, ol")).toReversed();
|
||||
for (const list of lists) {
|
||||
const replacement = document.createDocumentFragment();
|
||||
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
|
||||
const start = list.tagName === "OL" ? Number(list.getAttribute("start") ?? 1) : null;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const line = document.createElement("div");
|
||||
line.append(start === null ? "- " : `${start + index}. `);
|
||||
let hasParagraph = false;
|
||||
for (const child of Array.from(item.childNodes)) {
|
||||
if (child instanceof HTMLParagraphElement) {
|
||||
if (hasParagraph) {
|
||||
line.append(document.createElement("br"), document.createElement("br"));
|
||||
}
|
||||
line.append(...child.childNodes);
|
||||
hasParagraph = true;
|
||||
} else {
|
||||
line.append(child);
|
||||
}
|
||||
}
|
||||
replacement.append(line);
|
||||
});
|
||||
list.replaceWith(replacement);
|
||||
}
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
function cloneMarkdownSelection(range: Range, message: Element): Node {
|
||||
const clonedMessage = message.cloneNode(true) as Element;
|
||||
const clonedRange = cloneRangeInto(range, message, clonedMessage);
|
||||
removeUnselectedSemantics(range, message, clonedMessage);
|
||||
|
||||
let selected: Node = clonedRange.cloneContents();
|
||||
let ancestor =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
|
||||
while (ancestor && ancestor !== message) {
|
||||
const wrapper = ancestor.cloneNode(false) as Element;
|
||||
wrapper.append(selected);
|
||||
selected = wrapper;
|
||||
if (shouldPreserveSemanticElement(range, ancestor)) {
|
||||
const wrapper = ancestor.cloneNode(false) as Element;
|
||||
normalizeOrderedListStart(wrapper, ancestor, range);
|
||||
wrapper.append(selected);
|
||||
selected = wrapper;
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
function normalizeOrderedListStarts(selected: Node, range: Range, message: Element): void {
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="ol"]`;
|
||||
const originals = Array.from(message.querySelectorAll(selector)).filter((list) =>
|
||||
range.intersectsNode(list),
|
||||
function cloneRangeInto(range: Range, source: Element, clone: Element): Range {
|
||||
const clonedRange = document.createRange();
|
||||
clonedRange.setStart(
|
||||
nodeAtPath(clone, nodePath(source, range.startContainer)),
|
||||
range.startOffset,
|
||||
);
|
||||
const copies = markedElements(selected, selector);
|
||||
|
||||
for (let index = 0; index < Math.min(originals.length, copies.length); index += 1) {
|
||||
const original = originals[index];
|
||||
const firstSelectedItem = Array.from(original.children).find(
|
||||
(child) =>
|
||||
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" && range.intersectsNode(child),
|
||||
);
|
||||
if (!firstSelectedItem) {
|
||||
continue;
|
||||
}
|
||||
const omittedItems = Array.from(original.children)
|
||||
.slice(0, Array.from(original.children).indexOf(firstSelectedItem))
|
||||
.filter((child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li").length;
|
||||
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
|
||||
copies[index].setAttribute(
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
String(originalStart + omittedItems),
|
||||
);
|
||||
}
|
||||
clonedRange.setEnd(nodeAtPath(clone, nodePath(source, range.endContainer)), range.endOffset);
|
||||
return clonedRange;
|
||||
}
|
||||
|
||||
function markedElements(root: Node, selector: string): Element[] {
|
||||
const elements = root instanceof Element && root.matches(selector) ? [root] : [];
|
||||
if ("querySelectorAll" in root) {
|
||||
elements.push(...Array.from((root as ParentNode).querySelectorAll(selector)));
|
||||
function nodePath(root: Node, node: Node): number[] {
|
||||
const path: number[] = [];
|
||||
let current = node;
|
||||
while (current !== root) {
|
||||
const parent = current.parentNode;
|
||||
if (!parent) {
|
||||
throw new Error("Selection boundary is outside its assistant message");
|
||||
}
|
||||
path.unshift(Array.from(parent.childNodes).findIndex((child) => child === current));
|
||||
current = parent;
|
||||
}
|
||||
return elements;
|
||||
return path;
|
||||
}
|
||||
|
||||
function nodeAtPath(root: Node, path: number[]): Node {
|
||||
let current = root;
|
||||
for (const index of path) {
|
||||
const child = current.childNodes[index];
|
||||
if (!child) {
|
||||
throw new Error("Cloned assistant message does not match the selection source");
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
|
||||
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
|
||||
const originals = Array.from(source.querySelectorAll(selector));
|
||||
const copies = Array.from(clone.querySelectorAll(selector));
|
||||
|
||||
originals.forEach((original, index) => {
|
||||
const copy = copies[index];
|
||||
if (!copy || shouldPreserveSemanticElement(range, original)) {
|
||||
if (copy) {
|
||||
normalizeOrderedListStart(copy, original, range);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag && isBlockBoundary(tag)) {
|
||||
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
|
||||
return;
|
||||
}
|
||||
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (copy.tagName === "A") {
|
||||
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shouldPreserveSemanticElement(range: Range, element: Element): boolean {
|
||||
if (!range.intersectsNode(element)) {
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (tag === "ol" || tag === "ul") {
|
||||
const selectedItems = selectedListItems(element, range);
|
||||
return selectedItems.some((item) => hasSelectedAllContents(range, item, true));
|
||||
}
|
||||
return hasSelectedAllContents(range, element, tag === "li");
|
||||
}
|
||||
|
||||
function selectedListItems(list: Element, range: Range): Element[] {
|
||||
return Array.from(list.children).filter(
|
||||
(child) =>
|
||||
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
|
||||
(child.contains(range.startContainer) ||
|
||||
child.contains(range.endContainer) ||
|
||||
hasSelectedAllContents(range, child, true)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
|
||||
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
|
||||
return;
|
||||
}
|
||||
const firstSelectedItem = selectedListItems(original, range)[0];
|
||||
if (!firstSelectedItem) {
|
||||
return;
|
||||
}
|
||||
const items = Array.from(original.children).filter(
|
||||
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
|
||||
);
|
||||
const omittedItems = items.indexOf(firstSelectedItem);
|
||||
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
|
||||
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
|
||||
}
|
||||
|
||||
function hasSelectedAllContents(range: Range, element: Element, includeIgnored = false): boolean {
|
||||
const contents = document.createRange();
|
||||
contents.selectNodeContents(element);
|
||||
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, contents) > 0) {
|
||||
const before = contents.cloneRange();
|
||||
before.setEnd(range.startContainer, range.startOffset);
|
||||
if (hasMarkdownContent(before.cloneContents(), includeIgnored)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, contents) < 0) {
|
||||
const after = contents.cloneRange();
|
||||
after.setStart(range.endContainer, range.endOffset);
|
||||
if (hasMarkdownContent(after.cloneContents(), includeIgnored)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
||||
if (!includeIgnored) {
|
||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
ignored.remove();
|
||||
}
|
||||
}
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.join(",");
|
||||
return Boolean(fragment.querySelector(visibleVoidSelector));
|
||||
}
|
||||
|
||||
function isBlockBoundary(tag: string): boolean {
|
||||
return tag === "li" || tag === "blockquote" || tag === "pre" || /^h[1-6]$/.test(tag);
|
||||
}
|
||||
|
||||
function isTableStructure(tag: string | null): boolean {
|
||||
return ["table", "thead", "tbody", "tr", "th", "td"].includes(tag ?? "");
|
||||
}
|
||||
|
||||
function closestAssistantMessage(node: Node): Element | null {
|
||||
|
||||
Reference in New Issue
Block a user