/** * Fenced code block. Three pieces stacked: * * ┌───────────────────────────────┐ * │ TS ⎘ │ ← header: lang label + copy button * ├───────────────────────────────┤ * │ const x = 1; │ ← code, horizontal-scroll, selectable * └───────────────────────────────┘ * * Two render paths: * * 1. Known language + Shiki ready: token runs from `codeToTokensBase` * rendered as nested `` children, each carrying its theme color * via inline `style.color`. Per-line wrapping is preserved by giving * each line its own outer ``. * * 2. Unknown language, engine unavailable, or first frame before init * finishes: plain `` of the raw source. Visually identical to * pre-Shiki behavior. * * Theme tracks `useColorScheme()` so the palette flips with system dark * mode without a remount. * * Copy button is PERSISTENTLY visible (no hover on touch). Tap copies the * raw code to the system clipboard, plays a light haptic, and flips to a * check mark for 2s — iOS does not surface a system notice on clipboard * write, so we own the feedback. */ import { useEffect, useRef, useState } from "react"; import { Pressable, ScrollView, View, } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import Svg, { Path, Rect } from "react-native-svg"; import { Text } from "@/components/ui/text"; import { THEME } from "@/lib/theme"; import { useColorScheme } from "@/lib/use-color-scheme"; import { CODE_BLOCK_CONTAINER_CLASS, CODE_BLOCK_LANG_LABEL_CLASS, CODE_BLOCK_TEXT_CLASS, } from "./tokens"; import { highlight, resolveLang, SHIKI_THEME_DARK, SHIKI_THEME_LIGHT, type HighlightedLine, } from "./shiki"; interface Props { code: string; lang?: string; /** * When `false`, opts the code lines out of RN `` so the * UIKit long-press selection magnifier doesn't compete with an outer * Pressable's onLongPress. Default true preserves the reader-surface * behaviour (issue description / chat message code blocks where users * expect to be able to copy via selection). See `Markdown.selectable` * for the full rationale. */ selectable?: boolean; } export function CodeBlock({ code, lang, selectable = true }: Props) { const { isDarkColorScheme } = useColorScheme(); const theme = isDarkColorScheme ? SHIKI_THEME_DARK : SHIKI_THEME_LIGHT; const resolvedLang = resolveLang(lang); const [lines, setLines] = useState(null); useEffect(() => { if (!resolvedLang) { setLines(null); return; } let cancelled = false; void highlight(code, resolvedLang, theme).then((result) => { if (!cancelled) setLines(result); }); return () => { cancelled = true; }; }, [code, resolvedLang, theme]); return ( {lines ? ( ) : ( )} ); } function PlainCode({ code, selectable, }: { code: string; selectable: boolean; }) { return ( {code} ); } function HighlightedCode({ lines, selectable, }: { lines: HighlightedLine[]; selectable: boolean; }) { // One outer per line so RN treats each line as its own typographic // run. An empty line gets a single space so it still occupies a row's // height — otherwise blank lines collapse to zero pixels. return ( {lines.map((line, i) => ( {line.tokens.length === 0 ? " " : line.tokens.map((t, j) => ( {t.content} ))} ))} ); } function CodeBlockHeader({ code, lang }: Props) { const { isDarkColorScheme } = useColorScheme(); const t = isDarkColorScheme ? THEME.dark : THEME.light; const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); // Cancel pending reset on unmount so an in-flight setTimeout doesn't fire // setState on a dead component. useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); const onCopy = async () => { try { await Clipboard.setStringAsync(code); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setCopied(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 2000); } catch { // Clipboard write failed (extremely rare on iOS). Silent — no // recovery path beats a confusing toast. } }; return ( {lang ? ( // flex-1 + mr-2 + numberOfLines guarantees the copy button never gets // pushed off-screen by a long language alias (e.g. `typescript-react-native`). {lang} ) : ( )} {copied ? ( ) : ( )} ); } // Inline SVG icons. react-native-svg primitives don't accept className via // NativeWind, so colors are passed in as props from the parent — which // reads them from `THEME[scheme]` so light/dark tracks the app theme. function CopyIcon({ color }: { color: string }) { return ( ); } function CheckIcon({ color }: { color: string }) { return ( ); }