Fix markdown code rendering in file previews
This commit is contained in:
@@ -1,15 +1,26 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, {
|
||||||
|
type ASTNode,
|
||||||
|
MarkdownIt,
|
||||||
|
type RenderRules,
|
||||||
|
} from "react-native-markdown-display";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
|
Linking,
|
||||||
ScrollView as RNScrollView,
|
ScrollView as RNScrollView,
|
||||||
Text,
|
Text,
|
||||||
|
type TextProps,
|
||||||
|
type TextStyle,
|
||||||
View,
|
View,
|
||||||
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||||
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
|
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
@@ -22,6 +33,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|||||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
|
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||||
@@ -46,6 +58,8 @@ interface FilePreviewBodyProps {
|
|||||||
imagePreviewUri: string | null;
|
imagePreviewUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -116,6 +130,295 @@ function clampLineSelection(input: {
|
|||||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarkdownInheritedTextProps {
|
||||||
|
inheritedStyles: TextStyle;
|
||||||
|
textStyle: TextStyle;
|
||||||
|
style?: TextStyle;
|
||||||
|
monoSurface?: boolean;
|
||||||
|
onPress?: TextProps["onPress"];
|
||||||
|
accessibilityRole?: TextProps["accessibilityRole"];
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownInheritedText({
|
||||||
|
inheritedStyles,
|
||||||
|
textStyle,
|
||||||
|
style: overrideStyle,
|
||||||
|
monoSurface,
|
||||||
|
onPress,
|
||||||
|
accessibilityRole,
|
||||||
|
children,
|
||||||
|
}: MarkdownInheritedTextProps) {
|
||||||
|
const style = useMemo(
|
||||||
|
() => [inheritedStyles, textStyle, overrideStyle],
|
||||||
|
[inheritedStyles, textStyle, overrideStyle],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<MarkdownTextSpan
|
||||||
|
monoSurface={monoSurface}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole={accessibilityRole}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownListItemContentProps {
|
||||||
|
contentStyle: ViewStyle;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||||
|
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||||
|
|
||||||
|
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||||
|
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||||
|
return <View style={style}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownListViewProps {
|
||||||
|
baseStyle: ViewStyle;
|
||||||
|
spacing: { marginTop: number; marginBottom: number };
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||||
|
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||||
|
return <View style={style}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePreviewMarkdownLinkProps {
|
||||||
|
href: string;
|
||||||
|
inheritedStyles: TextStyle;
|
||||||
|
linkStyle: TextStyle;
|
||||||
|
onLinkPress?: (url: string) => boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreviewMarkdownLink({
|
||||||
|
href,
|
||||||
|
inheritedStyles,
|
||||||
|
linkStyle,
|
||||||
|
onLinkPress,
|
||||||
|
children,
|
||||||
|
}: FilePreviewMarkdownLinkProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (!href) return;
|
||||||
|
if (onLinkPress?.(href) === false) return;
|
||||||
|
void Linking.openURL(href);
|
||||||
|
}, [href, onLinkPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownInheritedText
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={linkStyle}
|
||||||
|
accessibilityRole="link"
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkdownLinkHref(node: ASTNode): string {
|
||||||
|
const href = node.attributes?.href;
|
||||||
|
return typeof href === "string" ? href : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilePreviewMarkdownRules(): RenderRules {
|
||||||
|
return {
|
||||||
|
text: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText
|
||||||
|
key={node.key}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.text}
|
||||||
|
>
|
||||||
|
{node.content}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
textgroup: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText
|
||||||
|
key={node.key}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.textgroup}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
strong: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText
|
||||||
|
key={node.key}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.strong}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
em: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||||
|
{children}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
s: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||||
|
{children}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||||
|
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||||
|
code_block: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<HighlightedCodeBlock
|
||||||
|
key={node.key}
|
||||||
|
code={node.content}
|
||||||
|
language={null}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.code_block}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
fence: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<HighlightedCodeBlock
|
||||||
|
key={node.key}
|
||||||
|
code={node.content}
|
||||||
|
language={node.sourceInfo}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.fence}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code_inline: (
|
||||||
|
node: ASTNode,
|
||||||
|
_children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
inheritedStyles: TextStyle = {},
|
||||||
|
) => (
|
||||||
|
<MarkdownInheritedText
|
||||||
|
key={node.key}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
textStyle={styles.code_inline}
|
||||||
|
monoSurface
|
||||||
|
>
|
||||||
|
{node.content ?? ""}
|
||||||
|
</MarkdownInheritedText>
|
||||||
|
),
|
||||||
|
bullet_list: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownListView
|
||||||
|
key={node.key}
|
||||||
|
baseStyle={styles.bullet_list}
|
||||||
|
spacing={getMarkdownListSpacing(node, parent)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownListView>
|
||||||
|
),
|
||||||
|
ordered_list: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownListView
|
||||||
|
key={node.key}
|
||||||
|
baseStyle={styles.ordered_list}
|
||||||
|
spacing={getMarkdownListSpacing(node, parent)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownListView>
|
||||||
|
),
|
||||||
|
list_item: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => {
|
||||||
|
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||||
|
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||||
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={node.key} style={styles.list_item}>
|
||||||
|
<Text style={iconStyle}>{marker}</Text>
|
||||||
|
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
paragraph: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
) => (
|
||||||
|
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
|
||||||
|
{children}
|
||||||
|
</MarkdownParagraphView>
|
||||||
|
),
|
||||||
|
link: (
|
||||||
|
node: ASTNode,
|
||||||
|
children: ReactNode[],
|
||||||
|
_parent: ASTNode[],
|
||||||
|
styles: MarkdownStyles,
|
||||||
|
onLinkPress?: (url: string) => boolean,
|
||||||
|
) => (
|
||||||
|
<FilePreviewMarkdownLink
|
||||||
|
key={node.key}
|
||||||
|
href={getMarkdownLinkHref(node)}
|
||||||
|
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||||
|
linkStyle={styles.link}
|
||||||
|
onLinkPress={onLinkPress}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</FilePreviewMarkdownLink>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({
|
||||||
tokens,
|
tokens,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
@@ -198,6 +501,7 @@ function FilePreviewBody({
|
|||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||||
|
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
|
||||||
const isMarkdownFile =
|
const isMarkdownFile =
|
||||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||||
|
|
||||||
@@ -280,9 +584,11 @@ function FilePreviewBody({
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
>
|
>
|
||||||
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
<AppearanceStyleBoundary>
|
||||||
{preview.content ?? ""}
|
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||||
</Markdown>
|
{preview.content ?? ""}
|
||||||
|
</Markdown>
|
||||||
|
</AppearanceStyleBoundary>
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
{scrollbar.overlay}
|
{scrollbar.overlay}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user