| 1 | /** |
| 2 | * Converts a DOM selection inside rendered chat markdown back into markdown |
| 3 | * source so highlight-and-copy keeps formatting (links, emphasis, lists, |
| 4 | * fences, tables) instead of flattening to plain text. The `text/plain` |
| 5 | * clipboard flavor carries the markdown; `text/html` carries a sanitized |
| 6 | * copy of the rendered fragment for rich-paste targets. |
| 7 | */ |
| 8 | |
| 9 | const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]); |
| 10 | const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"]; |
| 11 | const SANITIZED_HTML_SELECTOR = [ |
| 12 | "button", |
| 13 | "input", |
| 14 | "script", |
| 15 | "style", |
| 16 | "svg", |
| 17 | '[aria-hidden="true"]', |
| 18 | ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`), |
| 19 | ].join(", "); |
| 20 | |
| 21 | export interface MarkdownClipboardPayload { |
| 22 | text: string; |
| 23 | html: string; |
| 24 | } |
| 25 | |
| 26 | function isSkippedElement(element: Element): boolean { |
| 27 | if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true; |
| 28 | if (element.getAttribute("aria-hidden") === "true") return true; |
| 29 | return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className)); |
| 30 | } |
| 31 | |
| 32 | /** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */ |
| 33 | function wrapInlineMarker(content: string, marker: string): string { |
| 34 | const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content); |
| 35 | const core = match?.[2] ?? ""; |
| 36 | if (!core) return content; |
| 37 | return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * A code element whose pre wrapper fell outside the copied range is still |
| 42 | * block code, recognizable by its highlighter line spans or embedded |
| 43 | * newlines. Wrapping it like inline code produces backtick-surrounded |
| 44 | * shell commands on paste. |
| 45 | */ |
| 46 | function isBlockCodeElement(element: Element, content: string): boolean { |
| 47 | if (content.includes("\n")) return true; |
| 48 | for (const child of element.childNodes) { |
| 49 | if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { |
| 50 | return true; |
| 51 | } |
| 52 | } |
| 53 | return false; |
| 54 | } |
| 55 | |
| 56 | function wrapInlineCode(code: string): string { |
| 57 | const longestRun = [...(code.match(/`+/g) ?? [])].reduce( |
| 58 | (max, run) => Math.max(max, run.length), |
| 59 | 0, |
| 60 | ); |
| 61 | const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0))); |
| 62 | const pad = code.startsWith("`") || code.endsWith("`") ? " " : ""; |
| 63 | return `${fence}${pad}${code}${pad}${fence}`; |
| 64 | } |
| 65 | |
| 66 | function codeFenceFor(code: string): string { |
| 67 | const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce( |
| 68 | (max, run) => Math.max(max, run.length), |
| 69 | 0, |
| 70 | ); |
| 71 | return "`".repeat(Math.max(3, longestRun + 1)); |
| 72 | } |
| 73 | |
| 74 | function resolveCodeBlockLanguage(pre: Element): string | null { |
| 75 | const declared = |
| 76 | pre.closest("[data-language]")?.getAttribute("data-language") ?? |
| 77 | /(?:^|\s)language-(\S+)/.exec(pre.querySelector("code")?.className ?? "")?.[1] ?? |
| 78 | null; |
| 79 | return declared && declared !== "text" ? declared : null; |
| 80 | } |
| 81 | |
| 82 | function serializeCodeBlock(pre: Element): string { |
| 83 | const code = (pre.textContent ?? "").replace(/\n$/, ""); |
| 84 | const fence = codeFenceFor(code); |
| 85 | return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`; |
| 86 | } |
| 87 | |
| 88 | function serializeTableCell(cell: Element): string { |
| 89 | return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|"); |
| 90 | } |
| 91 | |
| 92 | function tableSeparatorFor(headerCells: Element[]): string { |
| 93 | const markers = headerCells.map((cell) => { |
| 94 | const align = (cell as HTMLElement).style?.textAlign ?? cell.getAttribute("align") ?? ""; |
| 95 | if (align === "center") return ":---:"; |
| 96 | if (align === "right") return "---:"; |
| 97 | return "---"; |
| 98 | }); |
| 99 | return `| ${markers.join(" | ")} |`; |
| 100 | } |
| 101 | |
| 102 | function serializeTable(table: Element): string { |
| 103 | const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")]; |
| 104 | if (rows.length === 0) return ""; |
| 105 | const lines: string[] = []; |
| 106 | let emittedSeparator = false; |
| 107 | for (const row of rows) { |
| 108 | const cells = [...row.children].filter( |
| 109 | (cell) => cell.tagName === "TH" || cell.tagName === "TD", |
| 110 | ); |
| 111 | if (cells.length === 0) continue; |
| 112 | lines.push(`| ${cells.map((cell) => serializeTableCell(cell)).join(" | ")} |`); |
| 113 | if (!emittedSeparator) { |
| 114 | lines.push(tableSeparatorFor(cells)); |
| 115 | emittedSeparator = true; |
| 116 | } |
| 117 | } |
| 118 | return `${lines.join("\n")}\n\n`; |
| 119 | } |
| 120 | |
| 121 | function serializeListItem(item: Element, ordered: boolean, index: number): string { |
| 122 | const checkbox = item.querySelector( |
| 123 | ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]', |
| 124 | ); |
| 125 | const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; |
| 126 | const marker = ordered ? `${index}. ${task}` : `- ${task}`; |
| 127 | let content = serializeChildren(item) |
| 128 | .replace(/\n{3,}/g, "\n\n") |
| 129 | .trim(); |
| 130 | // Tight list items (no paragraph children) keep nested lists on adjacent lines. |
| 131 | if (!item.querySelector(":scope > p")) { |
| 132 | content = content.replace(/\n{2,}/g, "\n"); |
| 133 | } |
| 134 | const continuationIndent = " ".repeat(marker.length); |
| 135 | const [first = "", ...rest] = content.split("\n"); |
| 136 | return [ |
| 137 | `${marker}${first}`, |
| 138 | ...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)), |
| 139 | ].join("\n"); |
| 140 | } |
| 141 | |
| 142 | function serializeList(list: Element, ordered: boolean): string { |
| 143 | const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1; |
| 144 | const items = [...list.children].filter((child) => child.tagName === "LI"); |
| 145 | if (items.length === 0) return ""; |
| 146 | return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`; |
| 147 | } |
| 148 | |
| 149 | function serializeBlockquote(quote: Element): string { |
| 150 | const content = serializeChildren(quote) |
| 151 | .replace(/\n{3,}/g, "\n\n") |
| 152 | .trim(); |
| 153 | if (!content) return ""; |
| 154 | const quoted = content |
| 155 | .split("\n") |
| 156 | .map((line) => (line.length > 0 ? `> ${line}` : ">")) |
| 157 | .join("\n"); |
| 158 | return `${quoted}\n\n`; |
| 159 | } |
| 160 | |
| 161 | function serializeDetails(details: Element): string { |
| 162 | const summary = |
| 163 | details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ?? |
| 164 | "Details"; |
| 165 | const contentNode = details.querySelector(":scope > * [data-markdown-details-content]"); |
| 166 | const content = contentNode ? serializeChildren(contentNode).trim() : ""; |
| 167 | const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : ""; |
| 168 | return `<details${open}>\n<summary>${summary}</summary>${content ? `\n\n${content}` : ""}\n</details>\n\n`; |
| 169 | } |
| 170 | |
| 171 | function serializeAnchor(anchor: Element): string { |
| 172 | const markdownCopy = anchor.getAttribute("data-markdown-copy"); |
| 173 | if (markdownCopy !== null) return markdownCopy; |
| 174 | const content = serializeChildren(anchor); |
| 175 | const href = anchor.getAttribute("href") ?? ""; |
| 176 | if (!/^https?:\/\//i.test(href)) return content; |
| 177 | const label = content.trim(); |
| 178 | if (!label) return ""; |
| 179 | if (label === href) return href; |
| 180 | return `[${label}](${href})`; |
| 181 | } |
| 182 | |
| 183 | function serializeChildren(node: Node): string { |
| 184 | let out = ""; |
| 185 | for (const child of node.childNodes) { |
| 186 | out += serializeNode(child); |
| 187 | } |
| 188 | return out; |
| 189 | } |
| 190 | |
| 191 | function serializeNode(node: Node): string { |
| 192 | if (node.nodeType === Node.TEXT_NODE) { |
| 193 | const text = node.textContent ?? ""; |
| 194 | // Inter-block formatting whitespace from the renderer collapses to a |
| 195 | // newline; real inline whitespace passes through untouched. |
| 196 | if (text.includes("\n") && text.trim().length === 0) return "\n"; |
| 197 | return text; |
| 198 | } |
| 199 | if (node.nodeType !== Node.ELEMENT_NODE) return ""; |
| 200 | const element = node as Element; |
| 201 | if (element.hasAttribute("data-markdown-details")) { |
| 202 | return serializeDetails(element); |
| 203 | } |
| 204 | const markdownCopy = element.getAttribute("data-markdown-copy"); |
| 205 | if (markdownCopy !== null) return markdownCopy; |
| 206 | if (isSkippedElement(element)) return ""; |
| 207 | |
| 208 | const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; |
| 209 | if (headingLevel) { |
| 210 | return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`; |
| 211 | } |
| 212 | |
| 213 | switch (element.tagName) { |
| 214 | case "BR": |
| 215 | return "\n"; |
| 216 | case "HR": |
| 217 | return "---\n\n"; |
| 218 | case "P": |
| 219 | return `${serializeChildren(element).trim()}\n\n`; |
| 220 | case "PRE": |
| 221 | return serializeCodeBlock(element); |
| 222 | case "CODE": { |
| 223 | const content = element.textContent ?? ""; |
| 224 | return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); |
| 225 | } |
| 226 | case "STRONG": |
| 227 | case "B": |
| 228 | return wrapInlineMarker(serializeChildren(element), "**"); |
| 229 | case "EM": |
| 230 | case "I": |
| 231 | return wrapInlineMarker(serializeChildren(element), "*"); |
| 232 | case "DEL": |
| 233 | case "S": |
| 234 | return wrapInlineMarker(serializeChildren(element), "~~"); |
| 235 | case "A": |
| 236 | return serializeAnchor(element); |
| 237 | case "IMG": { |
| 238 | const alt = element.getAttribute("alt") ?? ""; |
| 239 | const src = element.getAttribute("src") ?? ""; |
| 240 | return alt && src ? `` : ""; |
| 241 | } |
| 242 | case "UL": |
| 243 | return serializeList(element, false); |
| 244 | case "OL": |
| 245 | return serializeList(element, true); |
| 246 | case "BLOCKQUOTE": |
| 247 | return serializeBlockquote(element); |
| 248 | case "TABLE": |
| 249 | return serializeTable(element); |
| 250 | case "DIV": |
| 251 | case "SECTION": |
| 252 | case "ARTICLE": { |
| 253 | const content = serializeChildren(element); |
| 254 | return content && !content.endsWith("\n") ? `${content}\n` : content; |
| 255 | } |
| 256 | default: |
| 257 | return serializeChildren(element); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Tracks whether a fragment carries exactly one code block and nothing else a |
| 263 | * reader would see. |
| 264 | */ |
| 265 | interface SoleCodeBlockScan { |
| 266 | pre: Element | null; |
| 267 | other: boolean; |
| 268 | } |
| 269 | |
| 270 | function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void { |
| 271 | for (const child of node.childNodes) { |
| 272 | if (scan.other) return; |
| 273 | if (child.nodeType === Node.TEXT_NODE) { |
| 274 | if ((child.textContent ?? "").trim().length > 0) scan.other = true; |
| 275 | continue; |
| 276 | } |
| 277 | if (child.nodeType !== Node.ELEMENT_NODE) continue; |
| 278 | const element = child as Element; |
| 279 | // Mirrors serializeNode's order: an element carrying markdown of its own |
| 280 | // still contributes it even when its tag is otherwise skipped, as a file |
| 281 | // chip rendered as a button does. |
| 282 | if (element.hasAttribute("data-markdown-details")) { |
| 283 | scan.other = true; |
| 284 | continue; |
| 285 | } |
| 286 | const markdownCopy = element.getAttribute("data-markdown-copy"); |
| 287 | if (markdownCopy !== null) { |
| 288 | if (markdownCopy.trim().length > 0) scan.other = true; |
| 289 | continue; |
| 290 | } |
| 291 | if (isSkippedElement(element)) continue; |
| 292 | if (element.tagName === "PRE") { |
| 293 | if (scan.pre) scan.other = true; |
| 294 | else scan.pre = element; |
| 295 | continue; |
| 296 | } |
| 297 | if (element.tagName === "IMG" || element.tagName === "HR") { |
| 298 | scan.other = true; |
| 299 | continue; |
| 300 | } |
| 301 | if (element.tagName === "LI") { |
| 302 | // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item, |
| 303 | // so an item that does not hold the block carries content of its own even |
| 304 | // when it renders no text. An item that wraps the block is just the |
| 305 | // structure around it, and a pre-only selection would drop the marker too. |
| 306 | const preBeforeItem = scan.pre; |
| 307 | scanForSoleCodeBlock(element, scan); |
| 308 | if (!scan.other && scan.pre === preBeforeItem) scan.other = true; |
| 309 | continue; |
| 310 | } |
| 311 | scanForSoleCodeBlock(element, scan); |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * A drag that ends on a block's final newline pulls the closing `pre` into the |
| 317 | * range, so the fragment holds the whole block even though the user only |
| 318 | * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose |
| 319 | * only visible content is one code block copies as plain code, matching a |
| 320 | * selection that never left the `pre`. |
| 321 | */ |
| 322 | function soleCodeBlock(container: Node): Element | null { |
| 323 | const scan: SoleCodeBlockScan = { pre: null, other: false }; |
| 324 | scanForSoleCodeBlock(container, scan); |
| 325 | return scan.other ? null : scan.pre; |
| 326 | } |
| 327 | |
| 328 | /** Collapses serializer spacing artifacts without touching fenced code content. */ |
| 329 | function tidyMarkdown(markdown: string): string { |
| 330 | return markdown |
| 331 | .split(/(```[\s\S]*?(?:```|$))/) |
| 332 | .map((part, index) => |
| 333 | index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"), |
| 334 | ) |
| 335 | .join("") |
| 336 | .trim(); |
| 337 | } |
| 338 | |
| 339 | export function serializeRenderedMarkdownFragment(container: Node): string { |
| 340 | const codeBlock = soleCodeBlock(container); |
| 341 | if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, ""); |
| 342 | return tidyMarkdown(serializeChildren(container)); |
| 343 | } |
| 344 | |
| 345 | export function serializeTableElementToMarkdown(table: Element): string { |
| 346 | return serializeTable(table).trim(); |
| 347 | } |
| 348 | |
| 349 | function csvCell(value: string): string { |
| 350 | const normalized = value.replace(/\s+/g, " ").trim(); |
| 351 | return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized; |
| 352 | } |
| 353 | |
| 354 | export function serializeTableElementToCsv(table: Element): string { |
| 355 | const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")]; |
| 356 | const lines: string[] = []; |
| 357 | for (const row of rows) { |
| 358 | const cells = [...row.children].filter( |
| 359 | (cell) => cell.tagName === "TH" || cell.tagName === "TD", |
| 360 | ); |
| 361 | if (cells.length === 0) continue; |
| 362 | lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(",")); |
| 363 | } |
| 364 | return lines.join("\n"); |
| 365 | } |
| 366 | |
| 367 | function sanitizedHtmlFrom(container: Element): string { |
| 368 | for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) { |
| 369 | if ( |
| 370 | node.classList.contains("chat-markdown-file-link") || |
| 371 | node.closest(".chat-markdown-file-link") |
| 372 | ) { |
| 373 | if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") { |
| 374 | node.remove(); |
| 375 | } |
| 376 | continue; |
| 377 | } |
| 378 | node.remove(); |
| 379 | } |
| 380 | return `<meta charset="utf-8">${container.innerHTML}`; |
| 381 | } |
| 382 | |
| 383 | export function chatMarkdownClipboardPayload( |
| 384 | selection: Selection, |
| 385 | ): MarkdownClipboardPayload | null { |
| 386 | const texts: string[] = []; |
| 387 | const htmls: string[] = []; |
| 388 | for (let index = 0; index < selection.rangeCount; index += 1) { |
| 389 | const range = selection.getRangeAt(index); |
| 390 | if (range.collapsed) continue; |
| 391 | const container = document.createElement("div"); |
| 392 | container.appendChild(range.cloneContents()); |
| 393 | const ancestor = range.commonAncestorContainer; |
| 394 | const ancestorElement = |
| 395 | ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; |
| 396 | if (ancestorElement?.closest("pre")) { |
| 397 | const text = range.toString(); |
| 398 | if (text) { |
| 399 | texts.push(text); |
| 400 | htmls.push(sanitizedHtmlFrom(container)); |
| 401 | } |
| 402 | continue; |
| 403 | } |
| 404 | const text = serializeRenderedMarkdownFragment(container); |
| 405 | if (!text) continue; |
| 406 | texts.push(text); |
| 407 | htmls.push(sanitizedHtmlFrom(container)); |
| 408 | } |
| 409 | if (texts.length === 0) return null; |
| 410 | return { text: texts.join("\n\n"), html: htmls.join("") }; |
| 411 | } |