124 lines · 4.0 KB · TypeScript
RawDownload
/** * Detects markdown that the renderer draws as a block requiring a definite * user-bubble width: fenced and indented code blocks, GFM tables, ordered * lists, and blockquotes when requested by the caller. * * Fenced code blocks and tables report an intrinsic width equal to their * widest line, which is effectively unbounded. A user bubble sizes itself * from its content * (`maxWidth` with no `width`), so Android lays the bubble's children out * during the unclamped intrinsic pass — where the surrounding paragraphs * collapse to a single line — and never repositions them once the width is * clamped back to `maxWidth`. The result is siblings drawn on top of each * other inside an over-tall bubble. Pinning the bubble's width removes the * intrinsic pass entirely, which is the same reason review-comment bubbles * already carry an explicit width. * * Ordered lists hit the same Android layout bug because each item contains a * flexing content column inside a shrink-to-fit row. * * A four-space ordered marker is only treated as a list when the preceding * non-empty line is a less-indented list item. This keeps standalone indented * code out of the heuristic while covering nested lists. */ const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;// Trades some precision for a simple check, favoring false positives over false// negatives: list-shaped paragraph may get a wider bubbleconst ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/;const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/;const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/;const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/; export interface WideMarkdownBlockOptions {  readonly includeOrderedLists?: boolean;  readonly includeBlockquotes?: boolean;} function stripBlockquotePrefixes(line: string): string {  let content = line;  while (BLOCKQUOTE_PREFIX.test(content)) {    content = content.replace(BLOCKQUOTE_PREFIX, "");  }  return content;} function hasIndentedCodeBlock(text: string): boolean {  return text.split("\n").some((rawLine) => {    const line = stripBlockquotePrefixes(rawLine);    let column = 0;    let index = 0;     // Markdown tabs advance to the next four-column stop.    while (index < line.length) {      if (line[index] === " ") {        column += 1;      } else if (line[index] === "\t") {        column += 4 - (column % 4);      } else {        break;      }      index += 1;    }     return column >= 4 && index < line.length && line[index] !== "\r";  });} function hasBlockquote(text: string): boolean {  return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line));} function hasOrderedListItem(text: string): boolean {  let previousNonEmptyLine: string | null = null;   for (const rawLine of text.split("\n")) {    const line = stripBlockquotePrefixes(rawLine);    if (ORDERED_LIST_ITEM.test(line)) {      return true;    }     const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line);    const parentMatch =      previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine);    if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) {      return true;    }     if (line.trim().length > 0) {      previousNonEmptyLine = line;    }  }   return false;} function isTableDelimiterRow(line: string): boolean {  const trimmed = line.trim();  if (!trimmed.includes("|") || !trimmed.includes("-")) {    return false;  }  return /^[|\-: \t]+$/.test(trimmed);} export function hasWideMarkdownBlock(  text: string,  options: WideMarkdownBlockOptions = {},): boolean {  if (FENCED_CODE_BLOCK.test(text)) {    return true;  }  if (options.includeBlockquotes === true && hasBlockquote(text)) {    return true;  }  if (hasIndentedCodeBlock(text)) {    return true;  }  if (options.includeOrderedLists !== false && hasOrderedListItem(text)) {    return true;  }  if (!text.includes("|")) {    return false;  }  return text.split("\n").some(isTableDelimiterRow);}