import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { MarkdownTextSpan } from "@/components/markdown-text"; import * as Clipboard from "expo-clipboard"; import { Check, Copy } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { HighlightToken } from "@getpaseo/highlight"; import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache"; interface HighlightedCodeBlockProps { code: string; language: string | null | undefined; inheritedStyles: TextStyle; textStyle: TextStyle; } // Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the // extension-based parser table in @getpaseo/highlight. Aliases here only // cover names that don't already match an extension key in parsers.ts. const LANGUAGE_ALIASES: Record = { typescript: "ts", javascript: "js", python: "py", rust: "rs", golang: "go", "c++": "cpp", objc: "m", "objective-c": "m", markdown: "md", elixir: "ex", }; function fenceLanguageToExtension(info: string | null | undefined): string | null { if (!info) return null; const first = info.trim().split(/\s+/)[0]?.toLowerCase(); if (!first) return null; const normalized = first.replace(/^\./, ""); return LANGUAGE_ALIASES[normalized] ?? normalized; } function stripTerminalFenceNewline(code: string): string { return code.endsWith("\n") ? code.slice(0, -1) : code; } export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ code, language, inheritedStyles, textStyle, }: HighlightedCodeBlockProps) { // Box styles (bg / padding / border / radius / margin) go on the wrapper View // so the absolute copy button positions relative to the visible code area, // not to a parent that includes the Text's own marginVertical. const { containerStyle, innerTextStyle } = useMemo( () => splitFenceStyle(inheritedStyles, textStyle), [inheritedStyles, textStyle], ); const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]); const keyedLines = useMemo( () => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)), [renderedCode, language], ); const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const controlsVisible = isHovered || isNative || isCompact; const getCode = useCallback(() => code, [code]); return ( {keyedLines ? ( {renderCodeSegments(keyedLines)} ) : ( {renderedCode} )} ); }); function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] { const segments: React.ReactNode[] = []; for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) { const line = keyedLines[lineIndex]; if (lineIndex > 0) { segments.push(); } for (const { key, token } of line.tokens) { segments.push(); } } return segments; } interface TokenSpanProps { token: HighlightToken; } const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) { return ( {token.text} ); }); interface CodeTextSpanProps { text: string; } const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) { return {text}; }); interface SplitStyles { containerStyle: StyleProp; innerTextStyle: StyleProp; } const CONTAINER_BASE: ViewStyle = { position: "relative" }; const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {}; function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles { const { fontFamily, fontSize, color, ...box } = textStyle; const textOnly: TextStyle = { ...WEB_SELECTABLE }; if (fontFamily !== undefined) textOnly.fontFamily = fontFamily; if (fontSize !== undefined) textOnly.fontSize = fontSize; if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45); if (color !== undefined) textOnly.color = color; return { containerStyle: [box as ViewStyle, CONTAINER_BASE], innerTextStyle: [inheritedStyles, textOnly], }; } interface CopyButtonProps { getCode: () => string; visible: boolean; } const COPIED_RESET_MS = 1500; const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const resetRef = useRef | null>(null); useEffect( () => () => { if (resetRef.current) clearTimeout(resetRef.current); }, [], ); const handlePress = useCallback(async () => { const content = getCode(); if (!content) return; await Clipboard.setStringAsync(content); setCopied(true); if (resetRef.current) clearTimeout(resetRef.current); resetRef.current = setTimeout(() => { setCopied(false); resetRef.current = null; }, COPIED_RESET_MS); }, [getCode]); const visibilityStyle = visible ? copyButtonStyles.containerVisible : copyButtonStyles.containerHidden; const wrapperStyle = useMemo( () => [copyButtonStyles.container, visibilityStyle], [visibilityStyle], ); return ( {({ hovered }) => { const iconColor = hovered ? copyButtonStyles.iconHoveredColor.color : copyButtonStyles.iconColor.color; return copied ? ( ) : ( ); }} ); }); const copyButtonStyles = StyleSheet.create((theme) => ({ container: { position: "absolute", top: theme.spacing[2], right: theme.spacing[2], padding: theme.spacing[1], }, containerVisible: { opacity: 1, }, containerHidden: { opacity: 0, }, iconColor: { color: theme.colors.foregroundMuted, }, iconHoveredColor: { color: theme.colors.foreground, }, }));