744 lines · 21.6 KB · TypeScript
RawDownload
import { createContext, useContext, useEffect, useState } from "react";import { Image, ScrollView, Text, useColorScheme, View } from "react-native";import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton";import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";import type {  MarkdownCodeHighlighter,  MarkdownHighlightedToken,  MarkdownImageRenderer,  NativeMarkdownTextStyle,  SelectableMarkdownSkill,} from "./SelectableMarkdownText.types"; /** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null); type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; const highlightedCodeCache = new Map<string, HighlightedCode>();const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; function nodeKey(node: MarkdownNode, index: number): string {  return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;} /** Code inside markdown scales with the base text size (12pt at the default 15pt body). */function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {  return Math.max(10, Math.round(textStyle.fontSize * 0.8));} function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {  return codeBlockFontSize(textStyle) + 6;} function nodeText(node: MarkdownNode): string {  if (node.content !== undefined) {    return node.content;  }  return (node.children ?? []).map(nodeText).join("");} function documentFor(node: MarkdownNode): MarkdownNode {  return node.type === "document" ? node : { type: "document", children: [node] };} function SelectableNode(props: {  readonly node: MarkdownNode;  readonly skills: ReadonlyArray<SelectableMarkdownSkill>;  readonly textStyle: NativeMarkdownTextStyle;  readonly onLinkPress?: (href: string) => void;}) {  return (    <NativeMarkdownSelectableText      runs={nativeMarkdownDocumentRuns(documentFor(props.node), props.skills)}      textStyle={props.textStyle}      onLinkPress={props.onLinkPress}    />  );} function codeHighlightCacheKey(  code: string,  language: string | undefined,  theme: "light" | "dark",): string {  return `${theme}:${language ?? "text"}:${code}`;} function cacheHighlightedCode(key: string, tokens: HighlightedCode): void {  highlightedCodeCache.delete(key);  highlightedCodeCache.set(key, tokens);   while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) {    const oldestKey = highlightedCodeCache.keys().next().value;    if (oldestKey === undefined) {      break;    }    highlightedCodeCache.delete(oldestKey);  }} function loadHighlightedCode(  code: string,  language: string | undefined,  theme: "light" | "dark",  highlightCode: MarkdownCodeHighlighter,): Promise<HighlightedCode> {  const key = codeHighlightCacheKey(code, language, theme);  const cached = highlightedCodeCache.get(key);  if (cached) {    return Promise.resolve(cached);  }   const pending = highlightedCodePromiseCache.get(key);  if (pending) {    return pending;  }   const promise = highlightCode({ code, language, theme })    .then((tokens) => {      cacheHighlightedCode(key, tokens);      highlightedCodePromiseCache.delete(key);      return tokens;    })    .catch((error) => {      highlightedCodePromiseCache.delete(key);      throw error;    });  highlightedCodePromiseCache.set(key, promise);  return promise;} function useHighlightedCode(  code: string,  language: string | undefined,  theme: "light" | "dark",  highlightCode: MarkdownCodeHighlighter,): HighlightedCode | null {  const key = codeHighlightCacheKey(code, language, theme);  const [highlighted, setHighlighted] = useState<{    readonly key: string;    readonly tokens: HighlightedCode | null;  }>(() => ({    key,    tokens: highlightedCodeCache.get(key) ?? null,  }));   useEffect(() => {    let active = true;    const cached = highlightedCodeCache.get(key);    if (cached) {      cacheHighlightedCode(key, cached);      setHighlighted({ key, tokens: cached });      return () => {        active = false;      };    }     void loadHighlightedCode(code, language, theme, highlightCode)      .then((tokens) => {        if (active) {          setHighlighted({ key, tokens });        }      })      .catch(() => {        if (active) {          setHighlighted({ key, tokens: null });        }      });    return () => {      active = false;    };  }, [code, highlightCode, key, language, theme]);   return highlighted.key === key ? highlighted.tokens : null;} function HighlightedCodeText(props: {  readonly content: string;  readonly highlighted: HighlightedCode | null;  readonly textStyle: NativeMarkdownTextStyle;}) {  if (!props.highlighted) {    return (      <MarkdownTextPrimitive        uiTextView        selectable        style={{          color: props.textStyle.codeColor,          fontFamily: "ui-monospace",          fontSize: codeBlockFontSize(props.textStyle),          lineHeight: codeBlockLineHeight(props.textStyle),        }}      >        {props.content}      </MarkdownTextPrimitive>    );  }  const highlighted = props.highlighted;  let sourceOffset = 0;  const keyOccurrences = new Map<string, number>();  const keyedLines = highlighted.map((line) => {    const lineStart = sourceOffset;    const tokens = line.map((token) => {      const start = sourceOffset;      sourceOffset += token.content.length;      const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`;      const occurrence = keyOccurrences.get(signature) ?? 0;      keyOccurrences.set(signature, occurrence + 1);      return { key: `${signature}:${occurrence}`, token };    });    sourceOffset += 1;    return {      key: `line:${lineStart}:${line.map((token) => token.content).join("")}`,      tokens,    };  });   return (    <MarkdownTextPrimitive      uiTextView      selectable      style={{        color: props.textStyle.codeColor,        fontFamily: "ui-monospace",        fontSize: codeBlockFontSize(props.textStyle),        lineHeight: codeBlockLineHeight(props.textStyle),      }}    >      {keyedLines.map((line, lineIndex) => (        <MarkdownTextPrimitive key={line.key}>          {line.tokens.map(({ key, token }) => (            <MarkdownTextPrimitive              key={key}              style={{                color: token.color ?? props.textStyle.codeColor,                fontFamily: "ui-monospace",                fontStyle:                  token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",                fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",              }}            >              {token.content}            </MarkdownTextPrimitive>          ))}          {lineIndex + 1 < keyedLines.length ? "\n" : ""}        </MarkdownTextPrimitive>      ))}    </MarkdownTextPrimitive>  );} function NativeCodeBlock(props: {  readonly node: MarkdownNode;  readonly textStyle: NativeMarkdownTextStyle;  readonly highlightCode: MarkdownCodeHighlighter;  readonly compact?: boolean;}) {  const content = nodeText(props.node).replace(/\n$/, "");  const colorScheme = useColorScheme();  const theme = colorScheme === "dark" ? "dark" : "light";  const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);  const languageLabel = props.node.language?.toUpperCase() ?? "CODE";  return (    <View      style={{        backgroundColor: props.textStyle.codeBlockBackgroundColor,        borderColor: props.textStyle.dividerColor,        borderCurve: "continuous",        borderRadius: 10,        borderWidth: 1,        marginVertical: props.compact ? 7 : 0,        overflow: "hidden",      }}    >      <View        style={{          minHeight: 42,          borderBottomColor: props.textStyle.dividerColor,          borderBottomWidth: 1,          paddingLeft: 14,          paddingRight: 6,          flexDirection: "row",          alignItems: "center",          justifyContent: "space-between",        }}      >        <Text          selectable          style={{            flex: 1,            color: props.textStyle.mutedColor,            fontFamily: "ui-monospace",            fontSize: codeBlockFontSize(props.textStyle),          }}        >          {languageLabel}        </Text>        <CopyTextButton          accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}          text={content}          tintColor={props.textStyle.mutedColor}          copiedTintColor={props.textStyle.linkColor}          backgroundColor={props.textStyle.codeBackgroundColor}          borderColor={props.textStyle.dividerColor}          buttonSize={34}          iconSize={14}        />      </View>      <ScrollView        horizontal        bounces={false}        showsHorizontalScrollIndicator={false}        contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}      >        <HighlightedCodeText          content={content}          highlighted={highlighted}          textStyle={props.textStyle}        />      </ScrollView>    </View>  );} function collectTableRows(node: MarkdownNode): MarkdownNode[] {  const rows: MarkdownNode[] = [];  const visit = (child: MarkdownNode) => {    if (child.type === "table_row") {      rows.push(child);      return;    }    for (const nested of child.children ?? []) {      visit(nested);    }  };  visit(node);  return rows;} function NativeTable(props: {  readonly node: MarkdownNode;  readonly skills: ReadonlyArray<SelectableMarkdownSkill>;  readonly textStyle: NativeMarkdownTextStyle;  readonly onLinkPress?: (href: string) => void;}) {  const rows = collectTableRows(props.node);  return (    <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}>      <View        style={{          borderColor: props.textStyle.dividerColor,          borderCurve: "continuous",          borderRadius: 8,          borderWidth: 1,          overflow: "hidden",        }}      >        {rows.map((row, rowIndex) => (          <View            key={nodeKey(row, rowIndex)}            style={{              flexDirection: "row",              backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent",              borderTopColor: props.textStyle.dividerColor,              borderTopWidth: rowIndex === 0 ? 0 : 1,            }}          >            {(row.children ?? []).map((cell, cellIndex) => (              <View                key={nodeKey(cell, cellIndex)}                style={{                  width: 160,                  borderLeftColor: props.textStyle.dividerColor,                  borderLeftWidth: cellIndex === 0 ? 0 : 1,                  paddingHorizontal: 10,                  paddingVertical: 8,                }}              >                <NativeMarkdownSelectableText                  runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) =>                    rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,                  )}                  textStyle={props.textStyle}                  onLinkPress={props.onLinkPress}                />              </View>            ))}          </View>        ))}      </View>    </ScrollView>  );} function NativeMarkdownImage(props: {  readonly node: MarkdownNode;  readonly skills: ReadonlyArray<SelectableMarkdownSkill>;  readonly textStyle: NativeMarkdownTextStyle;  readonly onLinkPress?: (href: string) => void;}) {  const renderImage = useContext(MarkdownImageRendererContext);  const href = props.node.href;  if (!href) {    return (      <SelectableNode        node={props.node}        skills={props.skills}        textStyle={props.textStyle}        onLinkPress={props.onLinkPress}      />    );  }   if (renderImage) {    const rendered = renderImage({      href,      alt: props.node.alt ?? null,      title: props.node.title ?? null,    });    if (rendered != null) {      return <>{rendered}</>;    }  }   return (    <View style={{ gap: 6 }}>      <Image        source={{ uri: href }}        resizeMode="contain"        accessibilityLabel={props.node.alt ?? props.node.title}        style={{          width: "100%",          aspectRatio: 16 / 9,          backgroundColor: props.textStyle.codeBackgroundColor,          borderRadius: 10,        }}      />      {props.node.alt ? (        <Text          selectable          style={{            color: props.textStyle.mutedColor,            fontFamily: props.textStyle.fontFamily,            fontSize: 12,            lineHeight: 16,          }}        >          {props.node.alt}        </Text>      ) : null}    </View>  );} function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {  const groups: MarkdownNode[] = [];  let inline: MarkdownNode[] = [];  const flush = () => {    if (inline.length === 0) {      return;    }    groups.push({ type: "paragraph", children: inline });    inline = [];  };   for (const node of nodes) {    if (node.type === "image") {      flush();      groups.push(node);    } else {      inline.push(node);    }  }  flush();  return groups;} function NativeMixedParagraph(props: {  readonly node: MarkdownNode;  readonly skills: ReadonlyArray<SelectableMarkdownSkill>;  readonly textStyle: NativeMarkdownTextStyle;  readonly onLinkPress?: (href: string) => void;}) {  return (    <View style={{ gap: 8 }}>      {inlineGroups(props.node.children ?? []).map((child, index) =>        child.type === "image" ? (          <NativeMarkdownImage            key={nodeKey(child, index)}            node={child}            skills={props.skills}            textStyle={props.textStyle}            onLinkPress={props.onLinkPress}          />        ) : (          <SelectableNode            key={nodeKey(child, index)}            node={child}            skills={props.skills}            textStyle={props.textStyle}            onLinkPress={props.onLinkPress}          />        ),      )}    </View>  );} function NativeList(props: {  readonly node: MarkdownNode;  readonly skills: ReadonlyArray<SelectableMarkdownSkill>;  readonly textStyle: NativeMarkdownTextStyle;  readonly highlightCode: MarkdownCodeHighlighter;  readonly onLinkPress?: (href: string) => void;  readonly depth: number;

Lines 1–500 of 744.