Improve Markdown file preview readability (#3240)
* feat(app): improve markdown file previews Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment. * fix(app): preserve unsafe frontmatter source * test(app): cover markdown link behavior
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
|
||||
export function colorMarkdownLinkChildren(
|
||||
children: ReactNode,
|
||||
color: TextStyle["color"],
|
||||
): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child;
|
||||
return cloneElement(child, { style: [child.props.style, { color }] });
|
||||
});
|
||||
}
|
||||
|
||||
const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" };
|
||||
|
||||
export function markdownLinkTextStyle(
|
||||
style: StyleProp<TextStyle>,
|
||||
hovered: boolean,
|
||||
): StyleProp<TextStyle> {
|
||||
return [style, hovered && MARKDOWN_LINK_HOVER_STYLE];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Pressable, Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { markdownLinkTextStyle } from "./link-children";
|
||||
|
||||
interface MarkdownLinkTextProps {
|
||||
style: StyleProp<TextStyle>;
|
||||
dataSet?: Record<string, string>;
|
||||
onPress(): void;
|
||||
onHoverIn?(): void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownLinkText({
|
||||
style,
|
||||
dataSet,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
children,
|
||||
}: MarkdownLinkTextProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
onHoverIn?.();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text dataSet={dataSet} style={textStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Pressable: ({
|
||||
accessibilityRole,
|
||||
children,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityRole?: string;
|
||||
children?: ReactNode;
|
||||
onHoverIn?(): void;
|
||||
onHoverOut?(): void;
|
||||
onPress?(): void;
|
||||
}) =>
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
onClick: onPress,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) =>
|
||||
createElement("span", { style: flattenStyle(style) }, children),
|
||||
}));
|
||||
|
||||
function flattenStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style]));
|
||||
}
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
@@ -27,3 +70,30 @@ describe("resolveInlineImageSize", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Markdown links", () => {
|
||||
it("renders accent text and underlines it while hovered", () => {
|
||||
const onPress = vi.fn();
|
||||
const children = colorMarkdownLinkChildren(
|
||||
createElement(Text, { style: { color: "white" } }, "Paseo"),
|
||||
"rgb(0, 122, 255)",
|
||||
);
|
||||
const view = render(
|
||||
createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children),
|
||||
);
|
||||
const link = view.getByRole("link");
|
||||
const linkText = link.firstElementChild as HTMLElement;
|
||||
|
||||
expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)");
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("underline");
|
||||
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(onPress).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -478,6 +481,15 @@ function SharedMarkdownLink({
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]);
|
||||
|
||||
if (!isNative) {
|
||||
return (
|
||||
<MarkdownLinkText style={style} onPress={handlePress}>
|
||||
{children}
|
||||
</MarkdownLinkText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
@@ -715,7 +727,7 @@ export function createSharedMarkdownRules(): RenderRules {
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
{colorMarkdownLinkChildren(children, styles.link.color)}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -23,9 +23,6 @@ import {
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
isValidElement,
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type MarkdownIt from "markdown-it";
|
||||
@@ -77,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
||||
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
@@ -1863,13 +1861,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||||
return cloneElement(child, {
|
||||
style: [childProps.style, { color: styles.link.color }],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||
})}
|
||||
{colorMarkdownLinkChildren(children, styles.link.color)}
|
||||
</AssistantMarkdownLink>
|
||||
),
|
||||
image: (
|
||||
|
||||
Reference in New Issue
Block a user