import { createContext, useCallback, useContext } from "react";import { findNodeHandle, Image, Linking, Platform, StyleSheet, Text as RNText, type TextStyle, useColorScheme,} from "react-native"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";import { markdownFileIconSource } from "./markdownFileIcons";import { markdownLinkIconSource } from "./markdownLinkIcons";import { resolveMarkdownLinkIcon } from "./markdownLinks";import type { NativeMarkdownTextRun } from "./nativeMarkdownText";import type { MarkdownFileContextMenu, NativeMarkdownTextStyle,} from "./SelectableMarkdownText.types";import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule"; export interface MarkdownFileContextMenuHandlers { readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction: (href: string, actionId: string) => void;} /** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>( null,); const EXTERNAL_LINK_PREFIX = "◉ ";const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";const SKILL_ICON_PLACEHOLDER = "\uFFFC";const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;const MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", android: "monospace", default: "monospace",});const styles = StyleSheet.create({ inlineIcon: { width: 14, height: 14, marginHorizontal: 3, transform: [{ translateY: 2 }], },}); function runKeySignature(run: NativeMarkdownTextRun): string { return [ run.text, run.bold, run.italic, run.strikethrough, run.code, run.href, run.externalHost, run.fileIcon, run.skillName, run.skillLabel, run.role, run.headingLevel, run.depth, run.spacing, run.firstLineHeadIndent, run.headIndent, run.paragraphSpacing, ].join(":");} const DEFAULT_BODY_FONT_SIZE = 15;const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { const index = Math.max(0, Math.min(5, headingLevel - 1)); const configured = textStyle.headingFontSizes?.[index]; if (typeof configured === "number" && Number.isFinite(configured)) { return configured; } const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));} function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { const isFile = run.fileIcon != null; const isSkill = run.skillName != null; const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); const isHeading = run.role === "heading"; const isCodeBlock = run.role === "code-block" || run.role === "code-language"; const hasParagraphStyle = run.headIndent !== undefined; const textDecorationLine = run.strikethrough ? "line-through" : run.href && !isFile ? "underline" : "none"; return { color: isFile ? textStyle.fileTextColor : isSkill ? textStyle.skillTextColor : run.href ? textStyle.linkColor : isHeading ? textStyle.strongColor : run.role === "quote-marker" ? textStyle.quoteMarkerColor : run.role === "divider" ? textStyle.dividerColor : run.role === "code-language" ? textStyle.mutedColor : run.role === "list-marker" ? textStyle.mutedColor : isCodeBlock ? textStyle.codeColor : run.code ? textStyle.inlineCodeColor : run.bold ? textStyle.strongColor : textStyle.color, fontFamily: isFile || isSkill ? textStyle.boldFontFamily : run.code || isCodeBlock ? MONO_FONT_FAMILY : isHeading ? textStyle.headingFontFamily : run.bold ? textStyle.boldFontFamily : textStyle.fontFamily, fontSize: run.role === "spacer" ? (run.spacing ?? 10) : run.role === "list-break" ? textStyle.fontSize : isHeading ? headingFontSize : run.role === "code-language" ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) : run.code || isCodeBlock ? Math.max(12, textStyle.fontSize - 2) : textStyle.fontSize, lineHeight: run.role === "spacer" ? (run.spacing ?? 10) : run.role === "list-break" ? textStyle.lineHeight + (run.spacing ?? 0) : isHeading ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) : isCodeBlock ? Math.max(16, textStyle.lineHeight - 2) : textStyle.lineHeight, fontStyle: run.italic ? "italic" : "normal", fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", textDecorationLine, backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined, ...(hasParagraphStyle ? { shadowColor: "transparent", shadowOffset: { width: run.firstLineHeadIndent ?? 0, height: run.headIndent, }, shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), } : {}), };} export function NativeMarkdownSelectableText(props: { readonly runs: ReadonlyArray<NativeMarkdownTextRun>; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void;}) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); const containsInlineIcon = props.runs.some( (run) => run.fileIcon != null || (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), ); const attachAndroidText = useCallback( (textView: RNText | null) => { if (Platform.OS !== "android" || !containsInlineIcon || textView === null) { return; } const reactTag = findNodeHandle(textView); if (reactTag !== null) { installMarkdownCopySanitizer(reactTag); } }, [containsInlineIcon], ); const occurrences = new Map<string, number>(); const prefixedExternalLinks = new Set<string>(); const keyedRuns = props.runs.map((run) => { const signature = runKeySignature(run); const occurrence = occurrences.get(signature) ?? 0; occurrences.set(signature, occurrence + 1); let text = run.text; let linkIcon = null; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { text = Platform.OS === "ios" ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); linkIcon = resolveMarkdownLinkIcon(run.externalHost); if (linkIcon === null) { text = `${EXTERNAL_LINK_PREFIX}${text}`; } else if (Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } } return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. const appearanceKey = [ colorScheme ?? "unspecified", props.textStyle.fontSize, props.textStyle.lineHeight, props.textStyle.headingFontSizes?.join(","), props.textStyle.color, props.textStyle.strongColor, props.textStyle.mutedColor, props.textStyle.linkColor, props.textStyle.inlineCodeColor, props.textStyle.codeColor, props.textStyle.codeBackgroundColor, props.textStyle.codeBlockBackgroundColor, props.textStyle.fileTextColor, props.textStyle.skillTextColor, props.textStyle.quoteMarkerColor, props.textStyle.dividerColor, ].join(":"); return ( <MarkdownTextPrimitive key={appearanceKey} nativeTextRef={attachAndroidText} uiTextView selectable style={{ flexShrink: 1, minWidth: 0, color: props.textStyle.color, fontFamily: props.textStyle.fontFamily, fontSize: props.textStyle.fontSize, lineHeight: props.textStyle.lineHeight, }} > {keyedRuns.map(({ key, run, text, linkIcon }) => { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( <MarkdownTextPrimitive key={key} nativeID={ Platform.OS === "ios" ? run.fileIcon ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName ? "t3-skill:sf:cube" : linkIcon ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` : undefined : undefined } contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} style={runStyle(run, props.textStyle)} onPress={ href ? () => { if (props.onLinkPress) { props.onLinkPress(href); } else { void Linking.openURL(href); } } : undefined } onContextMenuAction={ contextMenu && href && menu ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) : undefined } > {Platform.OS === "android" && run.fileIcon ? ( <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} /> ) : Platform.OS === "android" && linkIcon ? ( <Image source={markdownLinkIconSource(linkIcon)} style={styles.inlineIcon} tintColor={props.textStyle.linkColor} /> ) : null} {text} </MarkdownTextPrimitive> ); })} </MarkdownTextPrimitive> );}
import { createContext, useCallback, useContext } from "react";
import {
findNodeHandle,
Image,
Linking,
Platform,
StyleSheet,
Text as RNText,
type TextStyle,
useColorScheme,
} from "react-native";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
import { markdownFileIconSource } from "./markdownFileIcons";
import { markdownLinkIconSource } from "./markdownLinkIcons";
import { resolveMarkdownLinkIcon } from "./markdownLinks";
import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
import type {
MarkdownFileContextMenu,
NativeMarkdownTextStyle,
} from "./SelectableMarkdownText.types";
import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
export interface MarkdownFileContextMenuHandlers {
readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
readonly onFileContextMenuAction: (href: string, actionId: string) => void;
}
/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
null,
);
const EXTERNAL_LINK_PREFIX = "◉ ";
const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
const SKILL_ICON_PLACEHOLDER = "\uFFFC";
const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
const MONO_FONT_FAMILY = Platform.select({
ios: "ui-monospace",
android: "monospace",
default: "monospace",
});
const styles = StyleSheet.create({
inlineIcon: {
width: 14,
height: 14,
marginHorizontal: 3,
transform: [{ translateY: 2 }],
},
});
function runKeySignature(run: NativeMarkdownTextRun): string {
return [
run.text,
run.bold,
run.italic,
run.strikethrough,
run.code,
run.href,
run.externalHost,
run.fileIcon,
run.skillName,
run.skillLabel,
run.role,
run.headingLevel,
run.depth,
run.spacing,
run.firstLineHeadIndent,
run.headIndent,
run.paragraphSpacing,
].join(":");
}
const DEFAULT_BODY_FONT_SIZE = 15;
const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
const index = Math.max(0, Math.min(5, headingLevel - 1));
const configured = textStyle.headingFontSizes?.[index];
if (typeof configured === "number" && Number.isFinite(configured)) {
return configured;
}
const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale));
}
function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
const isFile = run.fileIcon != null;
const isSkill = run.skillName != null;
const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
const isHeading = run.role === "heading";
const isCodeBlock = run.role === "code-block" || run.role === "code-language";
const hasParagraphStyle = run.headIndent !== undefined;
const textDecorationLine = run.strikethrough
? "line-through"
: run.href && !isFile
? "underline"
: "none";
return {
color: isFile
? textStyle.fileTextColor
: isSkill
? textStyle.skillTextColor
: run.href
? textStyle.linkColor
: isHeading
? textStyle.strongColor
: run.role === "quote-marker"
? textStyle.quoteMarkerColor
: run.role === "divider"
? textStyle.dividerColor
: run.role === "code-language"
? textStyle.mutedColor
: run.role === "list-marker"
? textStyle.mutedColor
: isCodeBlock
? textStyle.codeColor
: run.code
? textStyle.inlineCodeColor
: run.bold
? textStyle.strongColor
: textStyle.color,
fontFamily:
isFile || isSkill
? textStyle.boldFontFamily
: run.code || isCodeBlock
? MONO_FONT_FAMILY
: isHeading
? textStyle.headingFontFamily
: run.bold
? textStyle.boldFontFamily
: textStyle.fontFamily,
fontSize:
run.role === "spacer"
? (run.spacing ?? 10)
: run.role === "list-break"
? textStyle.fontSize
: isHeading
? headingFontSize
: run.role === "code-language"
? Math.max(10, Math.round(textStyle.fontSize * 0.73))
: run.code || isCodeBlock
? Math.max(12, textStyle.fontSize - 2)
: textStyle.fontSize,
lineHeight:
run.role === "spacer"
? (run.spacing ?? 10)
: run.role === "list-break"
? textStyle.lineHeight + (run.spacing ?? 0)
: isHeading
? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
: isCodeBlock
? Math.max(16, textStyle.lineHeight - 2)
: textStyle.lineHeight,
fontStyle: run.italic ? "italic" : "normal",
fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
textDecorationLine,
backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined,
...(hasParagraphStyle
? {
shadowColor: "transparent",
shadowOffset: {
width: run.firstLineHeadIndent ?? 0,
height: run.headIndent,
},
shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0),
}
: {}),
};
}
export function NativeMarkdownSelectableText(props: {
readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
}) {
const colorScheme = useColorScheme();
const menu = useContext(MarkdownFileContextMenuContext);
const containsInlineIcon = props.runs.some(
(run) =>
run.fileIcon != null ||
(run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null),
);
const attachAndroidText = useCallback(
(textView: RNText | null) => {
if (Platform.OS !== "android" || !containsInlineIcon || textView === null) {
return;
}
const reactTag = findNodeHandle(textView);
if (reactTag !== null) {
installMarkdownCopySanitizer(reactTag);
}
},
[containsInlineIcon],
);
const occurrences = new Map<string, number>();
const prefixedExternalLinks = new Set<string>();
const keyedRuns = props.runs.map((run) => {
const signature = runKeySignature(run);
const occurrence = occurrences.get(signature) ?? 0;
occurrences.set(signature, occurrence + 1);
let text = run.text;
let linkIcon = null;
if (run.fileIcon && Platform.OS === "ios") {
text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
} else if (run.skillName && run.skillLabel) {
text =
Platform.OS === "ios"
? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`
: `$${run.skillName}`;
} else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
prefixedExternalLinks.add(run.href);
linkIcon = resolveMarkdownLinkIcon(run.externalHost);
if (linkIcon === null) {
text = `${EXTERNAL_LINK_PREFIX}${text}`;
} else if (Platform.OS === "ios") {
text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
}
}
return { key: `${signature}:${occurrence}`, run, text, linkIcon };
});
// T3MarkdownText only rebuilds its attributed string during native layout. A
// color-only child update can otherwise leave the previous appearance cached.
const appearanceKey = [
colorScheme ?? "unspecified",
props.textStyle.fontSize,
props.textStyle.lineHeight,
props.textStyle.headingFontSizes?.join(","),
props.textStyle.color,
props.textStyle.strongColor,
props.textStyle.mutedColor,
props.textStyle.linkColor,
props.textStyle.inlineCodeColor,
props.textStyle.codeColor,
props.textStyle.codeBackgroundColor,
props.textStyle.codeBlockBackgroundColor,
props.textStyle.fileTextColor,
props.textStyle.skillTextColor,
props.textStyle.quoteMarkerColor,
props.textStyle.dividerColor,
].join(":");
return (
<MarkdownTextPrimitive
key={appearanceKey}
nativeTextRef={attachAndroidText}
uiTextView
selectable
style={{
flexShrink: 1,
minWidth: 0,
color: props.textStyle.color,
fontFamily: props.textStyle.fontFamily,
fontSize: props.textStyle.fontSize,
lineHeight: props.textStyle.lineHeight,
}}
>
{keyedRuns.map(({ key, run, text, linkIcon }) => {
const href = run.href;
const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
return (
<MarkdownTextPrimitive
key={key}
nativeID={
Platform.OS === "ios"
? run.fileIcon
? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
: run.skillName
? "t3-skill:sf:cube"
: linkIcon
? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}`
: undefined
: undefined
}
contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
style={runStyle(run, props.textStyle)}
onPress={
href
? () => {
if (props.onLinkPress) {
props.onLinkPress(href);
} else {
void Linking.openURL(href);
}
}
: undefined
}
onContextMenuAction={
contextMenu && href && menu
? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
: undefined
}
>
{Platform.OS === "android" && run.fileIcon ? (
<Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
) : Platform.OS === "android" && linkIcon ? (
<Image
source={markdownLinkIconSource(linkIcon)}
style={styles.inlineIcon}
tintColor={props.textStyle.linkColor}
/>
) : null}
{text}
</MarkdownTextPrimitive>
);
})}
</MarkdownTextPrimitive>
);
}