import { useEffect, useMemo, useState } from "react"; import { Asset } from "expo-asset"; import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; import type { MarkdownFileIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; const EXTERNAL_LINK_PREFIX = "◉ "; const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; const CHIP_SUFFIX = "\u00A0"; const SKILL_ICON_PLACEHOLDER = "\uFFFC"; const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; function useFileIconUris(runs: ReadonlyArray) { const iconSignature = JSON.stringify( [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), ); const icons = useMemo( () => JSON.parse(iconSignature) as ReadonlyArray, [iconSignature], ); const [uris, setUris] = useState>(() => new Map()); useEffect(() => { let cancelled = false; void Promise.all( icons.map(async (icon) => { const source = markdownFileIconSource(icon); const fallbackUri = Image.resolveAssetSource(source).uri; if (typeof source !== "number" && typeof source !== "string") { return [icon, fallbackUri] as const; } try { const asset = Asset.fromModule(source); await asset.downloadAsync(); return [icon, asset.localUri ?? fallbackUri] as const; } catch { return [icon, fallbackUri] as const; } }), ).then((entries) => { if (!cancelled) { setUris(new Map(entries)); } }); return () => { cancelled = true; }; }, [icons]); return uris; } function runKeySignature(run: NativeMarkdownTextRun): string { return [ run.text, run.bold, run.italic, run.strikethrough, run.code, run.href, run.externalHost, run.fileIcon, run.skillName, run.skillLabel, run.role, run.headingLevel, run.depth, run.spacing, run.firstLineHeadIndent, run.headIndent, run.paragraphSpacing, ].join(":"); } function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { const isFile = run.fileIcon != null; const isSkill = run.skillName != null; const isChip = isFile || isSkill; const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; const isHeading = run.role === "heading"; const isCodeBlock = run.role === "code-block" || run.role === "code-language"; const hasParagraphStyle = run.headIndent !== undefined; const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; return { color: isFile ? textStyle.fileTextColor : isSkill ? textStyle.skillTextColor : run.href ? textStyle.linkColor : isHeading ? textStyle.strongColor : run.role === "quote-marker" ? textStyle.quoteMarkerColor : run.role === "divider" ? textStyle.dividerColor : run.role === "code-language" ? textStyle.mutedColor : run.role === "list-marker" ? textStyle.mutedColor : run.code || isFile ? textStyle.codeColor : run.bold ? textStyle.strongColor : textStyle.color, fontFamily: isChip ? "DMSans_500Medium" : run.code || isCodeBlock ? "ui-monospace" : isHeading ? textStyle.headingFontFamily : run.bold ? textStyle.boldFontFamily : textStyle.fontFamily, fontSize: run.role === "spacer" ? (run.spacing ?? 10) : run.role === "list-break" ? textStyle.fontSize : isHeading ? headingFontSize : run.role === "code-language" ? 11 : run.code || isChip || isCodeBlock ? Math.max(12, textStyle.fontSize - 2) : textStyle.fontSize, lineHeight: run.role === "spacer" ? (run.spacing ?? 10) : run.role === "list-break" ? textStyle.lineHeight + (run.spacing ?? 0) : isHeading ? Math.max(headingFontSize + 6, 20) : isCodeBlock ? 18 : textStyle.lineHeight, fontStyle: run.italic ? "italic" : "normal", fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", textDecorationLine, backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : isSkill ? textStyle.skillBackgroundColor : run.code ? textStyle.codeBackgroundColor : isFile ? textStyle.fileBackgroundColor : undefined, ...(hasParagraphStyle ? { shadowColor: "transparent", shadowOffset: { width: run.firstLineHeadIndent ?? 0, height: run.headIndent, }, shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), } : {}), }; } export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; }) { const colorScheme = useColorScheme(); const fileIconUris = useFileIconUris(props.runs); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { const signature = runKeySignature(run); const occurrence = occurrences.get(signature) ?? 0; occurrences.set(signature, occurrence + 1); let text = run.text; if (run.fileIcon) { text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; } else if (run.skillName && run.skillLabel) { text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); text = `${EXTERNAL_LINK_PREFIX}${text}`; } return { key: `${signature}:${occurrence}`, run, text }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. const appearanceKey = [ colorScheme ?? "unspecified", props.textStyle.color, props.textStyle.strongColor, props.textStyle.mutedColor, props.textStyle.linkColor, props.textStyle.codeColor, props.textStyle.codeBackgroundColor, props.textStyle.codeBlockBackgroundColor, props.textStyle.fileBackgroundColor, props.textStyle.fileTextColor, props.textStyle.skillBackgroundColor, props.textStyle.skillTextColor, props.textStyle.quoteMarkerColor, props.textStyle.dividerColor, ].join(":"); return ( {keyedRuns.map(({ key, run, text }) => { const href = run.href; return ( { void Linking.openURL(href); } : undefined } > {text} ); })} ); }