diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b60430..edfb7e16d309 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; +import { plainTextForCodeSelection, serializeRenderedMarkdownFragment } from "./markdown-clipboard"; const TEXT_NODE = 3; const ELEMENT_NODE = 1; @@ -93,3 +93,74 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); }); + +function rangeNode(closestPre: Element | null): Node { + return { + nodeType: ELEMENT_NODE, + closest: () => closestPre, + } as unknown as Node; +} + +function codeSelectionRange({ + commonAncestorPre, + trailingText, + trailingElement, +}: { + commonAncestorPre: Element | null; + trailingText: string; + trailingElement?: FakeElement; +}): Range { + const trailingContent = + trailingElement ?? new FakeElement("DIV").append(new FakeText(trailingText)); + return { + commonAncestorContainer: rangeNode(commonAncestorPre), + startContainer: rangeNode({} as Element), + cloneRange: () => + ({ + setStartAfter: vi.fn(), + cloneContents: () => asNode(trailingContent), + }) as unknown as Range, + toString: () => "sudo dnf remove alacritty", + } as unknown as Range; +} + +describe("plainTextForCodeSelection", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps a selection contained by pre plain", () => { + const range = codeSelectionRange({ commonAncestorPre: {} as Element, trailingText: "unused" }); + + expect(plainTextForCodeSelection(range)).toBe("sudo dnf remove alacritty"); + }); + + it("keeps a final-line selection plain when only its trailing boundary leaves pre", () => { + const range = codeSelectionRange({ commonAncestorPre: null, trailingText: "\n" }); + + expect(plainTextForCodeSelection(range)).toBe("sudo dnf remove alacritty"); + }); + + it("preserves markdown serialization when a code selection continues into prose", () => { + const range = codeSelectionRange({ + commonAncestorPre: null, + trailingText: "The following paragraph", + }); + + expect(plainTextForCodeSelection(range)).toBeNull(); + }); + + it("preserves markdown serialization for selected trailing nodes without text", () => { + const range = codeSelectionRange({ + commonAncestorPre: null, + trailingText: "", + trailingElement: new FakeElement("DIV").append(new FakeElement("HR")), + }); + + expect(plainTextForCodeSelection(range)).toBeNull(); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188c..69fc8aed50ee 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -309,6 +309,28 @@ function sanitizedHtmlFrom(container: Element): string { return `${container.innerHTML}`; } +function elementForNode(node: Node): Element | null { + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; +} + +/** + * Returns raw code for selections contained by `pre`, including final-line + * triple-click ranges whose trailing boundary leaves the block without + * selecting any following text. + */ +export function plainTextForCodeSelection(range: Range): string | null { + if (elementForNode(range.commonAncestorContainer)?.closest("pre")) { + return range.toString(); + } + + const startPre = elementForNode(range.startContainer)?.closest("pre"); + if (!startPre) return null; + + const trailingRange = range.cloneRange(); + trailingRange.setStartAfter(startPre); + return serializeRenderedMarkdownFragment(trailingRange.cloneContents()) ? null : range.toString(); +} + export function chatMarkdownClipboardPayload( selection: Selection, ): MarkdownClipboardPayload | null { @@ -319,11 +341,9 @@ export function chatMarkdownClipboardPayload( if (range.collapsed) continue; const container = document.createElement("div"); container.appendChild(range.cloneContents()); - const ancestor = range.commonAncestorContainer; - const ancestorElement = - ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; - if (ancestorElement?.closest("pre")) { - const text = range.toString(); + const plainCodeText = plainTextForCodeSelection(range); + if (plainCodeText !== null) { + const text = plainCodeText; if (text) { texts.push(text); htmls.push(sanitizedHtmlFrom(container));