import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { useCallback, useMemo, useState } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useFontFamily } from "../../lib/useFontFamily"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { ThreadMarkdownImage, ThreadMarkdownImageUnavailable, } from "../threads/ThreadMarkdownImage"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownImageRenderer, type NativeMarkdownTextStyle, } from "../../native/SelectableMarkdownText"; import { resolveWorkspaceFilePath } from "./filePath"; interface MarkdownPreviewStyles { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; readonly renderers: CustomRenderers; readonly nativeTextStyle: NativeMarkdownTextStyle; } function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles { const { appearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), [appearance.baseFontSize], ); const nativeMarkdownTypography = useMemo( () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); const theme = useUniwindTheme(); const body = theme["--color-md-body"]; const strong = theme["--color-md-strong"]; const link = theme["--color-md-link"]; const blockquoteBorder = theme["--color-md-blockquote-border"]; const blockquoteBackground = theme["--color-md-blockquote-bg"]; const codeBackground = theme["--color-md-code-bg"]; const codeText = theme["--color-md-code-text"]; const horizontalRule = theme["--color-md-hr"]; const regularFontFamily = useFontFamily("regular"); const mediumFontFamily = useFontFamily("medium"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { const renderers: CustomRenderers = { link: ({ href, children }) => ( { if (href) { void tryOpenExternalUrl(href, "markdown-link"); } }} style={{ color: link, textDecorationLine: "none", }} > {children} ), image: ({ node }) => node.href && renderImage ? (renderImage({ href: node.href, alt: node.alt ?? null, title: node.title ?? null, }) ?? undefined) : undefined, }; return { theme: { colors: { text: body, heading: strong, link, blockquote: blockquoteBorder, border: horizontalRule, surface: "transparent", surfaceLight: blockquoteBackground, accent: link, tableBorder: horizontalRule, tableHeader: blockquoteBackground, tableHeaderText: strong, tableRowOdd: blockquoteBackground, tableRowEven: "transparent", code: codeText, codeBackground, }, }, styles: { text: { color: body, fontFamily: regularFontFamily, fontSize: markdownFontSizes.m, lineHeight: markdownFontSizes.bodyLineHeight, }, heading: { color: strong, fontFamily: boldFontFamily, }, strong: { color: strong, fontFamily: boldFontFamily, }, link: { color: link, fontFamily: mediumFontFamily, }, blockquote: { backgroundColor: blockquoteBackground, borderLeftColor: blockquoteBorder, borderLeftWidth: 3, paddingLeft: 12, }, code: { backgroundColor: codeBackground, color: codeText, fontFamily: "ui-monospace", }, codeBlock: { backgroundColor: codeBackground, borderRadius: 12, color: codeText, fontFamily: "ui-monospace", padding: 12, }, hr: { backgroundColor: horizontalRule, }, }, renderers, nativeTextStyle: { color: body, strongColor: strong, mutedColor: body, linkColor: link, inlineCodeColor: codeText, codeColor: codeText, codeBackgroundColor: codeBackground, codeBlockBackgroundColor: codeBackground, fileTextColor: codeText, skillTextColor: codeText, quoteMarkerColor: blockquoteBorder, dividerColor: horizontalRule, fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, boldFontFamily, }, }; }, [ blockquoteBackground, blockquoteBorder, body, codeBackground, codeText, horizontalRule, link, markdownFontSizes, mediumFontFamily, nativeMarkdownTypography, regularFontFamily, renderImage, strong, boldFontFamily, ]); } export function FileMarkdownPreview(props: { readonly cwd: string; readonly environmentId: EnvironmentId; readonly markdown: string; readonly relativePath: string; readonly threadId: ThreadId; readonly onRefresh?: () => Promise | void; }) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { if (!props.onRefresh) { return; } setIsPullRefreshing(true); try { await props.onRefresh(); } finally { setIsPullRefreshing(false); } }, [props.onRefresh]); const markdownDirectory = useMemo( () => getBrowseDirectoryPath(resolveWorkspaceFilePath(props.cwd, props.relativePath)), [props.cwd, props.relativePath], ); const renderImage = useCallback( (image) => { const media = resolveMediaSource(image.href, { threadId: props.threadId, workspaceRoot: markdownDirectory, imageEmbed: true, }); if (media?.access === "direct") { return null; } if (media === null || media.kind !== "image" || media.access === "unavailable") { return ; } return ( undefined} /> ); }, [markdownDirectory, props.environmentId, props.threadId], ); const styles = useMarkdownPreviewStyles(renderImage); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); return ( void handlePullToRefresh()} /> ) : undefined } > {hasNativeSelectableMarkdownText() ? ( ) : ( {props.markdown} )} ); }