diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 8922c8868c4450acbc5cac5c79adbb05bd3c5212..376befbeb152c34ddcecee736fa17f51025940a8 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -25,7 +25,7 @@ "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", - "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./renderer": "./src/SelectableMarkdownText.tsx", "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx deleted file mode 100644 index 348a3c489a2c4458d04446ba8a078c6d1d88ef70..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ /dev/null @@ -1,755 +0,0 @@ -import { createContext, useContext, useEffect, useState } from "react"; -import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; - -import { CopyTextButton } from "./CopyTextButton"; -import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; -import type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, -} from "./SelectableMarkdownText.types"; - -/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ -export const MarkdownImageRendererContext = createContext(null); - -type HighlightedCode = ReadonlyArray>; - -const highlightedCodeCache = new Map(); -const highlightedCodePromiseCache = new Map>(); -const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; -const MONO_FONT_FAMILY = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); - -function nodeKey(node: MarkdownNode, index: number): string { - return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; -} - -/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ -function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { - return Math.max(10, Math.round(textStyle.fontSize * 0.8)); -} - -function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { - return codeBlockFontSize(textStyle) + 6; -} - -function nodeText(node: MarkdownNode): string { - if (node.content !== undefined) { - return node.content; - } - return (node.children ?? []).map(nodeText).join(""); -} - -function documentFor(node: MarkdownNode): MarkdownNode { - return node.type === "document" ? node : { type: "document", children: [node] }; -} - -function SelectableNode(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - ); -} - -function codeHighlightCacheKey( - code: string, - language: string | undefined, - theme: "light" | "dark", -): string { - return `${theme}:${language ?? "text"}:${code}`; -} - -function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { - highlightedCodeCache.delete(key); - highlightedCodeCache.set(key, tokens); - - while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { - const oldestKey = highlightedCodeCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - highlightedCodeCache.delete(oldestKey); - } -} - -function loadHighlightedCode( - code: string, - language: string | undefined, - theme: "light" | "dark", - highlightCode: MarkdownCodeHighlighter, -): Promise { - const key = codeHighlightCacheKey(code, language, theme); - const cached = highlightedCodeCache.get(key); - if (cached) { - return Promise.resolve(cached); - } - - const pending = highlightedCodePromiseCache.get(key); - if (pending) { - return pending; - } - - const promise = highlightCode({ code, language, theme }) - .then((tokens) => { - cacheHighlightedCode(key, tokens); - highlightedCodePromiseCache.delete(key); - return tokens; - }) - .catch((error) => { - highlightedCodePromiseCache.delete(key); - throw error; - }); - highlightedCodePromiseCache.set(key, promise); - return promise; -} - -function useHighlightedCode( - code: string, - language: string | undefined, - theme: "light" | "dark", - highlightCode: MarkdownCodeHighlighter, -): HighlightedCode | null { - const key = codeHighlightCacheKey(code, language, theme); - const [highlighted, setHighlighted] = useState<{ - readonly key: string; - readonly tokens: HighlightedCode | null; - }>(() => ({ - key, - tokens: highlightedCodeCache.get(key) ?? null, - })); - - useEffect(() => { - let active = true; - const cached = highlightedCodeCache.get(key); - if (cached) { - cacheHighlightedCode(key, cached); - setHighlighted({ key, tokens: cached }); - return () => { - active = false; - }; - } - - void loadHighlightedCode(code, language, theme, highlightCode) - .then((tokens) => { - if (active) { - setHighlighted({ key, tokens }); - } - }) - .catch(() => { - if (active) { - setHighlighted({ key, tokens: null }); - } - }); - return () => { - active = false; - }; - }, [code, highlightCode, key, language, theme]); - - return highlighted.key === key ? highlighted.tokens : null; -} - -function HighlightedCodeText(props: { - readonly content: string; - readonly highlighted: HighlightedCode | null; - readonly textStyle: NativeMarkdownTextStyle; -}) { - if (!props.highlighted) { - return ( - - {props.content} - - ); - } - const highlighted = props.highlighted; - let sourceOffset = 0; - const keyOccurrences = new Map(); - const keyedLines = highlighted.map((line) => { - const lineStart = sourceOffset; - const tokens = line.map((token) => { - const start = sourceOffset; - sourceOffset += token.content.length; - const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; - const occurrence = keyOccurrences.get(signature) ?? 0; - keyOccurrences.set(signature, occurrence + 1); - return { key: `${signature}:${occurrence}`, token }; - }); - sourceOffset += 1; - return { - key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, - tokens, - }; - }); - - return ( - - {keyedLines.map((line, lineIndex) => ( - - {line.tokens.map(({ key, token }) => ( - - {token.content} - - ))} - {lineIndex + 1 < keyedLines.length ? "\n" : ""} - - ))} - - ); -} - -function NativeCodeBlock(props: { - readonly node: MarkdownNode; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly compact?: boolean; -}) { - const content = nodeText(props.node).replace(/\n$/, ""); - const colorScheme = useColorScheme(); - const theme = colorScheme === "dark" ? "dark" : "light"; - const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); - const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; - return ( - - - - {languageLabel} - - - - - - - - ); -} - -function collectTableRows(node: MarkdownNode): MarkdownNode[] { - const rows: MarkdownNode[] = []; - const visit = (child: MarkdownNode) => { - if (child.type === "table_row") { - rows.push(child); - return; - } - for (const nested of child.children ?? []) { - visit(nested); - } - }; - visit(node); - return rows; -} - -function NativeTable(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const rows = collectTableRows(props.node); - return ( - - - {rows.map((row, rowIndex) => ( - - {(row.children ?? []).map((cell, cellIndex) => ( - - - rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, - )} - textStyle={props.textStyle} - onLinkPress={props.onLinkPress} - /> - - ))} - - ))} - - - ); -} - -function NativeMarkdownImage(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const renderImage = useContext(MarkdownImageRendererContext); - const href = props.node.href; - if (!href) { - return ( - - ); - } - - if (renderImage) { - const rendered = renderImage({ - href, - alt: props.node.alt ?? null, - title: props.node.title ?? null, - }); - if (rendered != null) { - return <>{rendered}; - } - } - - return ( - - - {props.node.alt ? ( - - {props.node.alt} - - ) : null} - - ); -} - -function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { - const groups: MarkdownNode[] = []; - let inline: MarkdownNode[] = []; - const flush = () => { - if (inline.length === 0) { - return; - } - groups.push({ type: "paragraph", children: inline }); - inline = []; - }; - - for (const node of nodes) { - if (node.type === "image") { - flush(); - groups.push(node); - } else { - inline.push(node); - } - } - flush(); - return groups; -} - -function NativeMixedParagraph(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - {inlineGroups(props.node.children ?? []).map((child, index) => - child.type === "image" ? ( - - ) : ( - - ), - )} - - ); -} - -function NativeList(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth: number; -}) { - const ordered = props.node.ordered ?? false; - const start = props.node.start ?? 1; - const nested = props.depth > 0; - return ( - - {(props.node.children ?? []).map((item, index) => { - const taskMarker = item.type === "task_list_item"; - const marker = taskMarker - ? item.checked - ? "☑︎" - : "☐︎" - : ordered - ? `${start + index}.` - : props.depth % 3 === 1 - ? "◦" - : props.depth % 3 === 2 - ? "▪︎" - : "•"; - const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; - const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; - return ( - - - - {marker} - - - - {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( - - ))} - - - ); - })} - - ); -} - -export function NativeMarkdownBlock(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth?: number; - readonly compact?: boolean; -}) { - const depth = props.depth ?? 0; - switch (props.node.type) { - case "document": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - case "code_block": - return ( - - ); - case "table": - return ( - - ); - case "image": - return ( - - ); - case "horizontal_rule": - return ( - - ); - case "blockquote": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - case "list": - return ( - - ); - case "paragraph": - return (props.node.children ?? []).some((child) => child.type === "image") ? ( - - ) : ( - - ); - case "html_block": - case "math_block": - return ( - - - - ); - case "table_head": - case "table_body": - case "table_row": - case "table_cell": - case "list_item": - case "task_list_item": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - default: - return ( - - ); - } -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ab75eaf32a2c83eba82e38547c1821604665c998 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -0,0 +1,755 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { CopyTextButton } from "./CopyTextButton"; +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; +import type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + MarkdownImageRenderer, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "./SelectableMarkdownText.types"; + +/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ +export const MarkdownImageRendererContext = createContext(null); + +type HighlightedCode = ReadonlyArray>; + +const highlightedCodeCache = new Map(); +const highlightedCodePromiseCache = new Map>(); +const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); + +function nodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; +} + +/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ +function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { + return Math.max(10, Math.round(textStyle.fontSize * 0.8)); +} + +function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { + return codeBlockFontSize(textStyle) + 6; +} + +function nodeText(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function documentFor(node: MarkdownNode): MarkdownNode { + return node.type === "document" ? node : { type: "document", children: [node] }; +} + +function SelectableNode(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + return ( + + ); +} + +function codeHighlightCacheKey( + code: string, + language: string | undefined, + theme: "light" | "dark", +): string { + return `${theme}:${language ?? "text"}:${code}`; +} + +function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { + highlightedCodeCache.delete(key); + highlightedCodeCache.set(key, tokens); + + while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { + const oldestKey = highlightedCodeCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + highlightedCodeCache.delete(oldestKey); + } +} + +function loadHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): Promise { + const key = codeHighlightCacheKey(code, language, theme); + const cached = highlightedCodeCache.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = highlightedCodePromiseCache.get(key); + if (pending) { + return pending; + } + + const promise = highlightCode({ code, language, theme }) + .then((tokens) => { + cacheHighlightedCode(key, tokens); + highlightedCodePromiseCache.delete(key); + return tokens; + }) + .catch((error) => { + highlightedCodePromiseCache.delete(key); + throw error; + }); + highlightedCodePromiseCache.set(key, promise); + return promise; +} + +function useHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): HighlightedCode | null { + const key = codeHighlightCacheKey(code, language, theme); + const [highlighted, setHighlighted] = useState<{ + readonly key: string; + readonly tokens: HighlightedCode | null; + }>(() => ({ + key, + tokens: highlightedCodeCache.get(key) ?? null, + })); + + useEffect(() => { + let active = true; + const cached = highlightedCodeCache.get(key); + if (cached) { + cacheHighlightedCode(key, cached); + setHighlighted({ key, tokens: cached }); + return () => { + active = false; + }; + } + + void loadHighlightedCode(code, language, theme, highlightCode) + .then((tokens) => { + if (active) { + setHighlighted({ key, tokens }); + } + }) + .catch(() => { + if (active) { + setHighlighted({ key, tokens: null }); + } + }); + return () => { + active = false; + }; + }, [code, highlightCode, key, language, theme]); + + return highlighted.key === key ? highlighted.tokens : null; +} + +function HighlightedCodeText(props: { + readonly content: string; + readonly highlighted: HighlightedCode | null; + readonly textStyle: NativeMarkdownTextStyle; +}) { + if (!props.highlighted) { + return ( + + {props.content} + + ); + } + const highlighted = props.highlighted; + let sourceOffset = 0; + const keyOccurrences = new Map(); + const keyedLines = highlighted.map((line) => { + const lineStart = sourceOffset; + const tokens = line.map((token) => { + const start = sourceOffset; + sourceOffset += token.content.length; + const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const occurrence = keyOccurrences.get(signature) ?? 0; + keyOccurrences.set(signature, occurrence + 1); + return { key: `${signature}:${occurrence}`, token }; + }); + sourceOffset += 1; + return { + key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + tokens, + }; + }); + + return ( + + {keyedLines.map((line, lineIndex) => ( + + {line.tokens.map(({ key, token }) => ( + + {token.content} + + ))} + {lineIndex + 1 < keyedLines.length ? "\n" : ""} + + ))} + + ); +} + +function NativeCodeBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly compact?: boolean; +}) { + const content = nodeText(props.node).replace(/\n$/, ""); + const colorScheme = useColorScheme(); + const theme = colorScheme === "dark" ? "dark" : "light"; + const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); + const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; + return ( + + + + {languageLabel} + + + + + + + + ); +} + +function collectTableRows(node: MarkdownNode): MarkdownNode[] { + const rows: MarkdownNode[] = []; + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + rows.push(child); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return rows; +} + +function NativeTable(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + const rows = collectTableRows(props.node); + return ( + + + {rows.map((row, rowIndex) => ( + + {(row.children ?? []).map((cell, cellIndex) => ( + + + rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, + )} + textStyle={props.textStyle} + onLinkPress={props.onLinkPress} + /> + + ))} + + ))} + + + ); +} + +function NativeMarkdownImage(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + const renderImage = useContext(MarkdownImageRendererContext); + const href = props.node.href; + if (!href) { + return ( + + ); + } + + if (renderImage) { + const rendered = renderImage({ + href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }); + if (rendered != null) { + return <>{rendered}; + } + } + + return ( + + + {props.node.alt ? ( + + {props.node.alt} + + ) : null} + + ); +} + +function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { + const groups: MarkdownNode[] = []; + let inline: MarkdownNode[] = []; + const flush = () => { + if (inline.length === 0) { + return; + } + groups.push({ type: "paragraph", children: inline }); + inline = []; + }; + + for (const node of nodes) { + if (node.type === "image") { + flush(); + groups.push(node); + } else { + inline.push(node); + } + } + flush(); + return groups; +} + +function NativeMixedParagraph(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly onLinkPress?: (href: string) => void; +}) { + return ( + + {inlineGroups(props.node.children ?? []).map((child, index) => + child.type === "image" ? ( + + ) : ( + + ), + )} + + ); +} + +function NativeList(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; + readonly depth: number; +}) { + const ordered = props.node.ordered ?? false; + const start = props.node.start ?? 1; + const nested = props.depth > 0; + return ( + + {(props.node.children ?? []).map((item, index) => { + const taskMarker = item.type === "task_list_item"; + const marker = taskMarker + ? item.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : props.depth % 3 === 1 + ? "◦" + : props.depth % 3 === 2 + ? "▪︎" + : "•"; + const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; + const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; + return ( + + + + {marker} + + + + {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( + + ))} + + + ); + })} + + ); +} + +export function NativeMarkdownBlock(props: { + readonly node: MarkdownNode; + readonly skills: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly onLinkPress?: (href: string) => void; + readonly depth?: number; + readonly compact?: boolean; +}) { + const depth = props.depth ?? 0; + switch (props.node.type) { + case "document": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "code_block": + return ( + + ); + case "table": + return ( + + ); + case "image": + return ( + + ); + case "horizontal_rule": + return ( + + ); + case "blockquote": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "list": + return ( + + ); + case "paragraph": + return (props.node.children ?? []).some((child) => child.type === "image") ? ( + + ) : ( + + ); + case "html_block": + case "math_block": + return ( + + + + ); + case "table_head": + case "table_body": + case "table_row": + case "table_cell": + case "list_item": + case "task_list_item": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + default: + return ( + + ); + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx deleted file mode 100644 index a5c6cf540f1cde16d35d40ef8638dba8f60bb2fd..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ /dev/null @@ -1,314 +0,0 @@ -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( - 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; - 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(); - const prefixedExternalLinks = new Set(); - 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 ( - - {keyedRuns.map(({ key, run, text, linkIcon }) => { - const href = run.href; - const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; - return ( - { - 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 ? ( - - ) : Platform.OS === "android" && linkIcon ? ( - - ) : null} - {text} - - ); - })} - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5c6cf540f1cde16d35d40ef8638dba8f60bb2fd --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -0,0 +1,314 @@ +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( + 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; + 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(); + const prefixedExternalLinks = new Set(); + 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 ( + + {keyedRuns.map(({ key, run, text, linkIcon }) => { + const href = run.href; + const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; + return ( + { + 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 ? ( + + ) : Platform.OS === "android" && linkIcon ? ( + + ) : null} + {text} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx deleted file mode 100644 index 2a231c6035840a458aa90ce714b5cf333e10f03f..0000000000000000000000000000000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useMemo } from "react"; -import { View } from "react-native"; -import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; - -import { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownWithPreservedSoftBreaks, -} from "./nativeMarkdownText"; -import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; -import { - MarkdownFileContextMenuContext, - NativeMarkdownSelectableText, - type MarkdownFileContextMenuHandlers, -} from "./NativeMarkdownSelectableText.ios"; -import type { - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -const EMPTY_SKILLS: ReadonlyArray = []; - -export type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - MarkdownImageRequest, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -export function hasNativeSelectableMarkdownText(): boolean { - return true; -} - -export function SelectableMarkdownText({ - markdown, - skills = EMPTY_SKILLS, - textStyle, - highlightCode, - preserveSoftBreaks = false, - onLinkPress, - fileContextMenu, - onFileContextMenuAction, - renderImage, - marginTop = 0, - marginBottom = 0, -}: SelectableMarkdownTextProps) { - const chunks = useMemo(() => { - const parsedDocument = parseMarkdownWithOptions(markdown, { - gfm: true, - html: true, - math: false, - }); - const document = preserveSoftBreaks - ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) - : parsedDocument; - return nativeMarkdownDocumentChunks(document).map((chunk) => - chunk.kind === "selectable" - ? { - ...chunk, - runs: nativeMarkdownDocumentRuns(chunk.node, skills), - } - : chunk, - ); - }, [markdown, preserveSoftBreaks, skills]); - - const fileContextMenuHandlers = useMemo( - () => - fileContextMenu && onFileContextMenuAction - ? { fileContextMenu, onFileContextMenuAction } - : null, - [fileContextMenu, onFileContextMenuAction], - ); - - return ( - - - {/* A percentage width here creates a cyclic intrinsic measurement inside - shrink-to-fit containers such as user-message bubbles. Yoga then gives - the native text node an unbounded second pass and the parent only clips - the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); - - return ( - - {content} - - ); - })} - - - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index 006d33e7259de911d5edead8ffbb1fa64f4fd23e..188a93b30e1292f50dc9ef44acb03aa21bf5c02f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -1,4 +1,25 @@ -import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownWithPreservedSoftBreaks, +} from "./nativeMarkdownText"; +import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; +import { + MarkdownFileContextMenuContext, + NativeMarkdownSelectableText, + type MarkdownFileContextMenuHandlers, +} from "./NativeMarkdownSelectableText"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; export type { MarkdownCodeHighlighter, @@ -10,6 +31,87 @@ export type { SelectableMarkdownTextProps, } from "./SelectableMarkdownText.types"; -export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { - return null; +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + preserveSoftBreaks = false, + onLinkPress, + fileContextMenu, + onFileContextMenuAction, + renderImage, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const parsedDocument = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + const document = preserveSoftBreaks + ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) + : parsedDocument; + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [markdown, preserveSoftBreaks, skills]); + + const fileContextMenuHandlers = useMemo( + () => + fileContextMenu && onFileContextMenuAction + ? { fileContextMenu, onFileContextMenuAction } + : null, + [fileContextMenu, onFileContextMenuAction], + ); + + return ( + + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + + + ); }