409 lines · 14.3 KB · TypeScript
RawDownload
/** * Converts a DOM selection inside rendered chat markdown back into markdown * source so highlight-and-copy keeps formatting (links, emphasis, lists, * fences, tables) instead of flattening to plain text. The `text/plain` * clipboard flavor carries the markdown; `text/html` carries a sanitized * copy of the rendered fragment for rich-paste targets. */ const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];const SANITIZED_HTML_SELECTOR = [  "button",  "input",  "script",  "style",  "svg",  '[aria-hidden="true"]',  ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`),].join(", "); export interface MarkdownClipboardPayload {  text: string;  html: string;} function isSkippedElement(element: Element): boolean {  if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true;  if (element.getAttribute("aria-hidden") === "true") return true;  return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className));} /** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */function wrapInlineMarker(content: string, marker: string): string {  const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content);  const core = match?.[2] ?? "";  if (!core) return content;  return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;} /** * A code element whose pre wrapper fell outside the copied range is still * block code, recognizable by its highlighter line spans or embedded * newlines. Wrapping it like inline code produces backtick-surrounded * shell commands on paste. */function isBlockCodeElement(element: Element, content: string): boolean {  if (content.includes("\n")) return true;  for (const child of element.childNodes) {    if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {      return true;    }  }  return false;} function wrapInlineCode(code: string): string {  const longestRun = [...(code.match(/`+/g) ?? [])].reduce(    (max, run) => Math.max(max, run.length),    0,  );  const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0)));  const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";  return `${fence}${pad}${code}${pad}${fence}`;} function codeFenceFor(code: string): string {  const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce(    (max, run) => Math.max(max, run.length),    0,  );  return "`".repeat(Math.max(3, longestRun + 1));} function resolveCodeBlockLanguage(pre: Element): string | null {  const declared =    pre.closest("[data-language]")?.getAttribute("data-language") ??    /(?:^|\s)language-(\S+)/.exec(pre.querySelector("code")?.className ?? "")?.[1] ??    null;  return declared && declared !== "text" ? declared : null;} function serializeCodeBlock(pre: Element): string {  const code = (pre.textContent ?? "").replace(/\n$/, "");  const fence = codeFenceFor(code);  return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`;} function serializeTableCell(cell: Element): string {  return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|");} function tableSeparatorFor(headerCells: Element[]): string {  const markers = headerCells.map((cell) => {    const align = (cell as HTMLElement).style?.textAlign ?? cell.getAttribute("align") ?? "";    if (align === "center") return ":---:";    if (align === "right") return "---:";    return "---";  });  return `| ${markers.join(" | ")} |`;} function serializeTable(table: Element): string {  const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];  if (rows.length === 0) return "";  const lines: string[] = [];  let emittedSeparator = false;  for (const row of rows) {    const cells = [...row.children].filter(      (cell) => cell.tagName === "TH" || cell.tagName === "TD",    );    if (cells.length === 0) continue;    lines.push(`| ${cells.map((cell) => serializeTableCell(cell)).join(" | ")} |`);    if (!emittedSeparator) {      lines.push(tableSeparatorFor(cells));      emittedSeparator = true;    }  }  return `${lines.join("\n")}\n\n`;} function serializeListItem(item: Element, ordered: boolean, index: number): string {  const checkbox = item.querySelector('input[type="checkbox"]');  const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";  const marker = ordered ? `${index}. ${task}` : `- ${task}`;  let content = serializeChildren(item)    .replace(/\n{3,}/g, "\n\n")    .trim();  // Tight list items (no paragraph children) keep nested lists on adjacent lines.  if (!item.querySelector(":scope > p")) {    content = content.replace(/\n{2,}/g, "\n");  }  const continuationIndent = " ".repeat(marker.length);  const [first = "", ...rest] = content.split("\n");  return [    `${marker}${first}`,    ...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)),  ].join("\n");} function serializeList(list: Element, ordered: boolean): string {  const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1;  const items = [...list.children].filter((child) => child.tagName === "LI");  if (items.length === 0) return "";  return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`;} function serializeBlockquote(quote: Element): string {  const content = serializeChildren(quote)    .replace(/\n{3,}/g, "\n\n")    .trim();  if (!content) return "";  const quoted = content    .split("\n")    .map((line) => (line.length > 0 ? `> ${line}` : ">"))    .join("\n");  return `${quoted}\n\n`;} function serializeDetails(details: Element): string {  const summary =    details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ??    "Details";  const contentNode = details.querySelector(":scope > * [data-markdown-details-content]");  const content = contentNode ? serializeChildren(contentNode).trim() : "";  const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : "";  return `<details${open}>\n<summary>${summary}</summary>${content ? `\n\n${content}` : ""}\n</details>\n\n`;} function serializeAnchor(anchor: Element): string {  const markdownCopy = anchor.getAttribute("data-markdown-copy");  if (markdownCopy !== null) return markdownCopy;  const content = serializeChildren(anchor);  const href = anchor.getAttribute("href") ?? "";  if (!/^https?:\/\//i.test(href)) return content;  const label = content.trim();  if (!label) return "";  if (label === href) return href;  return `[${label}](${href})`;} function serializeChildren(node: Node): string {  let out = "";  for (const child of node.childNodes) {    out += serializeNode(child);  }  return out;} function serializeNode(node: Node): string {  if (node.nodeType === Node.TEXT_NODE) {    const text = node.textContent ?? "";    // Inter-block formatting whitespace from the renderer collapses to a    // newline; real inline whitespace passes through untouched.    if (text.includes("\n") && text.trim().length === 0) return "\n";    return text;  }  if (node.nodeType !== Node.ELEMENT_NODE) return "";  const element = node as Element;  if (element.hasAttribute("data-markdown-details")) {    return serializeDetails(element);  }  const markdownCopy = element.getAttribute("data-markdown-copy");  if (markdownCopy !== null) return markdownCopy;  if (isSkippedElement(element)) return "";   const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];  if (headingLevel) {    return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`;  }   switch (element.tagName) {    case "BR":      return "\n";    case "HR":      return "---\n\n";    case "P":      return `${serializeChildren(element).trim()}\n\n`;    case "PRE":      return serializeCodeBlock(element);    case "CODE": {      const content = element.textContent ?? "";      return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);    }    case "STRONG":    case "B":      return wrapInlineMarker(serializeChildren(element), "**");    case "EM":    case "I":      return wrapInlineMarker(serializeChildren(element), "*");    case "DEL":    case "S":      return wrapInlineMarker(serializeChildren(element), "~~");    case "A":      return serializeAnchor(element);    case "IMG": {      const alt = element.getAttribute("alt") ?? "";      const src = element.getAttribute("src") ?? "";      return alt && src ? `![${alt}](${src})` : "";    }    case "UL":      return serializeList(element, false);    case "OL":      return serializeList(element, true);    case "BLOCKQUOTE":      return serializeBlockquote(element);    case "TABLE":      return serializeTable(element);    case "DIV":    case "SECTION":    case "ARTICLE": {      const content = serializeChildren(element);      return content && !content.endsWith("\n") ? `${content}\n` : content;    }    default:      return serializeChildren(element);  }} /** * Tracks whether a fragment carries exactly one code block and nothing else a * reader would see. */interface SoleCodeBlockScan {  pre: Element | null;  other: boolean;} function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void {  for (const child of node.childNodes) {    if (scan.other) return;    if (child.nodeType === Node.TEXT_NODE) {      if ((child.textContent ?? "").trim().length > 0) scan.other = true;      continue;    }    if (child.nodeType !== Node.ELEMENT_NODE) continue;    const element = child as Element;    // Mirrors serializeNode's order: an element carrying markdown of its own    // still contributes it even when its tag is otherwise skipped, as a file    // chip rendered as a button does.    if (element.hasAttribute("data-markdown-details")) {      scan.other = true;      continue;    }    const markdownCopy = element.getAttribute("data-markdown-copy");    if (markdownCopy !== null) {      if (markdownCopy.trim().length > 0) scan.other = true;      continue;    }    if (isSkippedElement(element)) continue;    if (element.tagName === "PRE") {      if (scan.pre) scan.other = true;      else scan.pre = element;      continue;    }    if (element.tagName === "IMG" || element.tagName === "HR") {      scan.other = true;      continue;    }    if (element.tagName === "LI") {      // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item,      // so an item that does not hold the block carries content of its own even      // when it renders no text. An item that wraps the block is just the      // structure around it, and a pre-only selection would drop the marker too.      const preBeforeItem = scan.pre;      scanForSoleCodeBlock(element, scan);      if (!scan.other && scan.pre === preBeforeItem) scan.other = true;      continue;    }    scanForSoleCodeBlock(element, scan);  }} /** * A drag that ends on a block's final newline pulls the closing `pre` into the * range, so the fragment holds the whole block even though the user only * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose * only visible content is one code block copies as plain code, matching a * selection that never left the `pre`. */function soleCodeBlock(container: Node): Element | null {  const scan: SoleCodeBlockScan = { pre: null, other: false };  scanForSoleCodeBlock(container, scan);  return scan.other ? null : scan.pre;} /** Collapses serializer spacing artifacts without touching fenced code content. */function tidyMarkdown(markdown: string): string {  return markdown    .split(/(```[\s\S]*?(?:```|$))/)    .map((part, index) =>      index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"),    )    .join("")    .trim();} export function serializeRenderedMarkdownFragment(container: Node): string {  const codeBlock = soleCodeBlock(container);  if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, "");  return tidyMarkdown(serializeChildren(container));} export function serializeTableElementToMarkdown(table: Element): string {  return serializeTable(table).trim();} function csvCell(value: string): string {  const normalized = value.replace(/\s+/g, " ").trim();  return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized;} export function serializeTableElementToCsv(table: Element): string {  const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];  const lines: string[] = [];  for (const row of rows) {    const cells = [...row.children].filter(      (cell) => cell.tagName === "TH" || cell.tagName === "TD",    );    if (cells.length === 0) continue;    lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(","));  }  return lines.join("\n");} function sanitizedHtmlFrom(container: Element): string {  for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) {    if (      node.classList.contains("chat-markdown-file-link") ||      node.closest(".chat-markdown-file-link")    ) {      if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") {        node.remove();      }      continue;    }    node.remove();  }  return `<meta charset="utf-8">${container.innerHTML}`;} export function chatMarkdownClipboardPayload(  selection: Selection,): MarkdownClipboardPayload | null {  const texts: string[] = [];  const htmls: string[] = [];  for (let index = 0; index < selection.rangeCount; index += 1) {    const range = selection.getRangeAt(index);    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();      if (text) {        texts.push(text);        htmls.push(sanitizedHtmlFrom(container));      }      continue;    }    const text = serializeRenderedMarkdownFragment(container);    if (!text) continue;    texts.push(text);    htmls.push(sanitizedHtmlFrom(container));  }  if (texts.length === 0) return null;  return { text: texts.join("\n\n"), html: htmls.join("") };}