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(null); type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); const highlightedCodePromiseCache = new Map>(); 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; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { return ( ); } 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 { 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 ( {props.content} ); } const highlighted = props.highlighted; let sourceOffset = 0; const keyOccurrences = new Map(); 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 ( {keyedLines.map((line, lineIndex) => ( {line.tokens.map(({ key, token }) => ( {token.content} ))} {lineIndex + 1 < keyedLines.length ? "\n" : ""} ))} ); } 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 ( {languageLabel} ); } 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; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { const rows = collectTableRows(props.node); return ( {rows.map((row, rowIndex) => ( {(row.children ?? []).map((cell, cellIndex) => ( rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} onLinkPress={props.onLinkPress} /> ))} ))} ); } function NativeMarkdownImage(props: { readonly node: MarkdownNode; readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { const renderImage = useContext(MarkdownImageRendererContext); const href = props.node.href; if (!href) { return ( ); } if (renderImage) { const rendered = renderImage({ href, alt: props.node.alt ?? null, title: props.node.title ?? null, }); if (rendered != null) { return <>{rendered}; } } return ( {props.node.alt ? ( {props.node.alt} ) : null} ); } function inlineGroups(nodes: ReadonlyArray): 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; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { return ( {inlineGroups(props.node.children ?? []).map((child, index) => child.type === "image" ? ( ) : ( ), )} ); } function NativeList(props: { readonly node: MarkdownNode; readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; readonly depth: number; }) { const ordered = props.node.ordered ?? false; const start = props.node.start ?? 1; const nested = props.depth > 0; return ( {(props.node.children ?? []).map((item, index) => { const taskMarker = item.type === "task_list_item"; const marker = taskMarker ? item.checked ? "☑︎" : "☐︎" : ordered ? `${start + index}.` : props.depth % 3 === 1 ? "◦" : props.depth % 3 === 2 ? "▪︎" : "•"; const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; return ( {marker} {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( ))} ); })} ); } export function NativeMarkdownBlock(props: { readonly node: MarkdownNode; readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; readonly depth?: number; readonly compact?: boolean; }) { const depth = props.depth ?? 0; switch (props.node.type) { case "document": return ( {(props.node.children ?? []).map((child, index) => ( ))} ); case "code_block": return ( ); case "table": return ( ); case "image": return ( ); case "horizontal_rule": return ( ); case "blockquote": return ( {(props.node.children ?? []).map((child, index) => ( ))} ); case "list": return ( ); case "paragraph": return (props.node.children ?? []).some((child) => child.type === "image") ? ( ) : ( ); case "html_block": case "math_block": return ( ); case "table_head": case "table_body": case "table_row": case "table_cell": case "list_item": case "task_list_item": return ( {(props.node.children ?? []).map((child, index) => ( ))} ); default: return ( ); } }