multica/apps/mobile/lib/markdown/code-block.tsx

246 lines
7.2 KiB
TypeScript

/**
* 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 `<Text>` children, each carrying its theme color
* via inline `style.color`. Per-line wrapping is preserved by giving
* each line its own outer `<Text>`.
*
* 2. Unknown language, engine unavailable, or first frame before init
* finishes: plain `<Text>` 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 `<Text selectable>` 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<HighlightedLine[] | null>(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 (
<View className={CODE_BLOCK_CONTAINER_CLASS}>
<CodeBlockHeader code={code} lang={lang} />
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{lines ? (
<HighlightedCode lines={lines} selectable={selectable} />
) : (
<PlainCode code={code} selectable={selectable} />
)}
</ScrollView>
</View>
);
}
function PlainCode({
code,
selectable,
}: {
code: string;
selectable: boolean;
}) {
return (
<Text className={CODE_BLOCK_TEXT_CLASS} selectable={selectable}>
{code}
</Text>
);
}
function HighlightedCode({
lines,
selectable,
}: {
lines: HighlightedLine[];
selectable: boolean;
}) {
// One outer <Text> 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 (
<View>
{lines.map((line, i) => (
<Text
key={i}
className={CODE_BLOCK_TEXT_CLASS}
selectable={selectable}
>
{line.tokens.length === 0
? " "
: line.tokens.map((t, j) => (
<Text
key={j}
style={t.color ? { color: t.color } : undefined}
>
{t.content}
</Text>
))}
</Text>
))}
</View>
);
}
function CodeBlockHeader({ code, lang }: Props) {
const { isDarkColorScheme } = useColorScheme();
const t = isDarkColorScheme ? THEME.dark : THEME.light;
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<View className="flex-row items-center justify-between mb-1">
{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`).
<Text
className={`${CODE_BLOCK_LANG_LABEL_CLASS} flex-1 mr-2`}
numberOfLines={1}
>
{lang}
</Text>
) : (
<View className="flex-1" />
)}
<Pressable
onPress={onCopy}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={copied ? "Code copied" : "Copy code"}
>
{copied ? (
<CheckIcon color={t.success} />
) : (
<CopyIcon color={t.mutedForeground} />
)}
</Pressable>
</View>
);
}
// 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 (
<Svg width={14} height={14} viewBox="0 0 16 16" fill="none">
<Rect
x={5}
y={5}
width={9}
height={9}
rx={1.5}
stroke={color}
strokeWidth={1.4}
/>
<Path
d="M11 4.5V3.5A1.5 1.5 0 0 0 9.5 2H3.5A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11h1"
stroke={color}
strokeWidth={1.4}
strokeLinecap="round"
/>
</Svg>
);
}
function CheckIcon({ color }: { color: string }) {
return (
<Svg width={14} height={14} viewBox="0 0 16 16" fill="none">
<Path
d="M3.5 8.5L6.5 11.5L12.5 5"
stroke={color}
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}