/
githubmirror
/
client
Обзор
Документация
Войти
/
githubmirror
/
client
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
shared/common-adapters/markdown/react.tsx
634 строки
19 KB
chrisnojima
refactor(styles): theme via hooks, no android remount (#29515)
08 авг 2026, 02:24
Не верифицирован
08 авг 2026, 02:24
a41d38e
Код
Авторство
О чём код?
import * as React from 'react' import * as SM from '@khanacademy/simple-markdown' import {StyleSheet} from 'react-native' import type * as T from '@/constants/types' import * as Styles from '@/styles' import Text from '@/common-adapters/text' import type {StylesTextCrossPlatform} from '@/common-adapters/text.shared' import {Box2} from '@/common-adapters/box' import Spoiler from './spoiler' import NativeEmoji from '@/common-adapters/emoji/native-emoji' import type {StyleOverride} from '.' import type {default as ServiceDecorationType} from './service-decoration' const SimpleMarkdown = SM.default let ServiceDecoration: typeof ServiceDecorationType = () => { throw new Error('Failed to init markdown') } export const setServiceDecoration = (SDT: typeof ServiceDecorationType) => { ServiceDecoration = SDT } interface State { context?: string allowFontScaling?: boolean disallowAnimation?: boolean messageType?: T.Chat.MessageType paragraphTextClassName?: string selectable?: boolean styleOverride?: StyleOverride virtualText?: boolean key?: string | number | undefined inline?: boolean | null | undefined inParagraph?: boolean inBlockQuote?: boolean insideEM?: boolean insideStrong?: boolean } interface Node { content: SM.SingleASTNode raw?: string } const electronWrapStyle = { whiteSpace: 'pre-wrap', wordBreak: 'break-word', } as const // markdown spans take color/weight from the enclosing message text const electronInherit = { color: 'inherit', fontWeight: 'inherit', } as const // consumed from many non-component places deep inside simple-markdown's output tree (render-rule // callbacks called by simple-markdown, not React), so this can't be a hook. Instead it's a themed // lookup cached per theme, with markdownStyles below re-resolving the current theme on every // property access the way those callbacks (and index.tsx's plain object usage) expect. const getMarkdownStyleTable = Styles.createThemedValue(theme => { const built = { bigTextBlockStyle: Styles.platformStyles({ isElectron: { ...electronWrapStyle, ...electronInherit, display: 'block', }, isMobile: { fontSize: 32, lineHeight: 39.5, // matches 40 px height }, }), boldStyle: Styles.platformStyles({ common: {...Styles.globalStyles.fontBold}, isElectron: {color: 'inherit', ...electronWrapStyle}, isMobile: {color: undefined}, }), get codeSnippetBlockStyle() { return Styles.platformStyles({ common: { ...this.codeSnippetStyle, backgroundColor: theme.redLighter, ...Styles.marginV(Styles.globalMargins.xtiny), ...Styles.padding(Styles.globalMargins.xtiny, Styles.globalMargins.tiny), }, isElectron: { ...electronWrapStyle, color: theme.black, display: 'block', }, }) }, codeSnippetBlockTextStyle: Styles.platformStyles({ isMobile: { ...Styles.globalStyles.fontTerminal, backgroundColor: theme.redLighter, color: theme.black, fontSize: 15, ...Styles.marginV(Styles.globalMargins.xtiny), }, }), codeSnippetStyle: Styles.platformStyles({ common: { ...Styles.globalStyles.fontTerminal, ...Styles.globalStyles.rounded, backgroundColor: theme.redLighter, color: theme.blueDarkOrBlueLight, ...Styles.paddingH(Styles.globalMargins.xtiny), }, isElectron: { ...electronWrapStyle, fontSize: 12, }, isMobile: {fontSize: 15}, }), italicStyle: Styles.platformStyles({ common: {fontStyle: 'italic'}, isElectron: {...electronInherit, ...electronWrapStyle}, isMobile: {color: undefined, fontWeight: undefined}, }), neutralPreviewStyle: Styles.platformStyles({ isElectron: electronInherit, isMobile: {color: theme.black_50, fontWeight: undefined}, }), quoteStyle: Styles.platformStyles({ common: { backgroundColor: theme.redLighter, borderLeftColor: theme.grey, borderLeftWidth: 3, borderStyle: 'solid', color: theme.black, }, isElectron: { display: 'block', paddingLeft: Styles.globalMargins.small, }, isMobile: { paddingLeft: Styles.globalMargins.tiny, }, }), quoteStyleText: Styles.platformStyles({ common: { backgroundColor: 'transparent', color: theme.black, }, }), strikeStyle: Styles.platformStyles({ isElectron: { ...electronWrapStyle, ...electronInherit, textDecoration: 'line-through', }, isMobile: { fontWeight: undefined, textDecorationLine: 'line-through', }, }), textBlockStyle: Styles.platformStyles({ isAndroid: {lineHeight: undefined}, isElectron: {...electronInherit, display: 'block', ...electronWrapStyle}, }), wrapStyle: Styles.platformStyles({isElectron: electronWrapStyle}), } as const return isMobile ? (StyleSheet.create(built as unknown as Parameters<typeof StyleSheet.create>[0]) as unknown as typeof built) : built }) type MakeText<T> = { [K in keyof T]: StylesTextCrossPlatform } type MarkdownStyleTable = ReturnType<typeof getMarkdownStyleTable> export const markdownStyles = { get bigTextBlockStyle() { return getMarkdownStyleTable(Styles.getTheme()).bigTextBlockStyle }, get boldStyle() { return getMarkdownStyleTable(Styles.getTheme()).boldStyle }, get codeSnippetBlockStyle() { return getMarkdownStyleTable(Styles.getTheme()).codeSnippetBlockStyle }, get codeSnippetBlockTextStyle() { return getMarkdownStyleTable(Styles.getTheme()).codeSnippetBlockTextStyle }, get codeSnippetStyle() { return getMarkdownStyleTable(Styles.getTheme()).codeSnippetStyle }, get italicStyle() { return getMarkdownStyleTable(Styles.getTheme()).italicStyle }, get neutralPreviewStyle() { return getMarkdownStyleTable(Styles.getTheme()).neutralPreviewStyle }, get quoteStyle() { return getMarkdownStyleTable(Styles.getTheme()).quoteStyle }, get quoteStyleText() { return getMarkdownStyleTable(Styles.getTheme()).quoteStyleText }, get strikeStyle() { return getMarkdownStyleTable(Styles.getTheme()).strikeStyle }, get textBlockStyle() { return getMarkdownStyleTable(Styles.getTheme()).textBlockStyle }, get wrapStyle() { return getMarkdownStyleTable(Styles.getTheme()).wrapStyle }, } as MakeText<MarkdownStyleTable> const InlineCode = (p: {children: React.ReactNode; state: State}) => { const {children, state} = p return ( <Text type="Body" style={Styles.collapseStyles([markdownStyles.codeSnippetStyle, state.styleOverride?.inlineCode])} allowFontScaling={state.allowFontScaling} > {children} </Text> ) } const Fence = (p: {children: React.ReactNode; state: State}) => { const {children, state} = p return isMobile ? ( <Box2 direction="vertical" fullWidth={true}> <Text type="Body" style={Styles.collapseStyles([markdownStyles.codeSnippetBlockTextStyle, state.styleOverride?.fence])} allowFontScaling={state.allowFontScaling} > {children} </Text> </Box2> ) : ( <Text type="Body" style={Styles.collapseStyles([markdownStyles.codeSnippetBlockStyle, state.styleOverride?.fence])} allowFontScaling={state.allowFontScaling} > {children} </Text> ) } const reactComponentsForMarkdownType = { Array: { // basically a port of the old reactFor which we used before react: (arr: Array<SM.SingleASTNode>, output: SM.ReactOutput, state: State) => { const oldKey = state.key const result: Array<React.ReactNode | string> = [] // map nestedOutput over the ast, except group any text // nodes together into a single string output. let lastResult: React.ReactNode = null for (let i = 0; i < arr.length; i++) { state.key = String(i) const nodeOut = output(arr[i]!, state) if (typeof nodeOut === 'string' && typeof lastResult === 'string') { lastResult = lastResult + nodeOut result[result.length - 1] = lastResult } else { result.push(nodeOut) lastResult = nodeOut } } state.key = oldKey return result }, }, // On mobile we can't have raw text without a Text tag. So we make sure we are in a paragraph or we return a new text tag. If it's not mobile we can short circuit and just return the string blockQuote: { react: (node: Node, output: SM.ReactOutput, state: State) => { const oldInBlockQuote = state.inBlockQuote state.inBlockQuote = true const ret = ( <Box2 direction="vertical" fullWidth={true} key={state.key} style={markdownStyles.quoteStyle}> {output(node['content'], state)} </Box2> ) state.inBlockQuote = oldInBlockQuote return ret }, }, del: { react: (node: Node, output: SM.ReactOutput, state: State) => ( <Text type="Body" key={state.key} style={Styles.collapseStyles([markdownStyles.strikeStyle, state.styleOverride?.del])} allowFontScaling={state.allowFontScaling} > {output(node['content'], state)} </Text> ), }, em: { react: (node: Node, output: SM.ReactOutput, state: State) => { const oldInsideEM = state.insideEM state.insideEM = true const ret = ( <Text type="Body" key={state.key} style={Styles.collapseStyles([ markdownStyles.italicStyle, state.insideStrong && markdownStyles.boldStyle, state.styleOverride?.em, ])} allowFontScaling={state.allowFontScaling} > {output(node['content'], state)} </Text> ) state.insideEM = oldInsideEM return ret }, }, emoji: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <NativeEmoji emojiName={ //eslint-disable-next-line String(node['content']).toLowerCase() } size={state.styleOverride?.emojiSize?.size ?? 16} key={state.key} disableSelecting={state.virtualText} allowFontScaling={state.allowFontScaling} style={state.styleOverride?.emoji} /> ), }, fence: { react: (node: Node, _output: SM.ReactOutput, state: State) => { return ( <Fence key={state.key} state={state}> {node['content'] as React.ReactElement} </Fence> ) }, }, inlineCode: { react: (node: Node, _output: SM.ReactOutput, state: State) => { return ( <InlineCode key={state.key} state={state}> {node['content'] as React.ReactElement} </InlineCode> ) }, }, newline: { react: (_node: Node, output: SM.ReactOutput, state: State): React.ReactNode => !isMobile || state.inParagraph ? ( output({content: '\n', type: 'text'}, state) ) : ( <Text type="Body" key={state.key} style={Styles.collapseStyles([markdownStyles.textBlockStyle, state.styleOverride?.paragraph])} allowFontScaling={state.allowFontScaling} > {'\n'} </Text> ), }, paragraph: { react: (node: Node, output: SM.ReactOutput, state: State) => { const oldInParagraph = state.inParagraph state.inParagraph = true const ret = ( <Text className={state.paragraphTextClassName} type="Body" key={state.key} style={Styles.collapseStyles([ markdownStyles.textBlockStyle, state.styleOverride?.paragraph, state.inBlockQuote && markdownStyles.quoteStyleText, ])} allowFontScaling={state.allowFontScaling} selectable={state.selectable} > {output(node['content'], state)} </Text> ) state.inParagraph = oldInParagraph return ret }, }, serviceDecoration: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <ServiceDecoration json={node['content'] as unknown as string} key={state.key} allowFontScaling={state.allowFontScaling} messageType={state.messageType} styleOverride={state.styleOverride} styles={markdownStyles} disableBigEmojis={false} disableEmojiAnimation={false} /> ), }, spoiler: { react: (node: Node, output: SM.ReactOutput, state: State) => { return ( <Spoiler key={state.key} context={state.context} content={node['raw'] ?? ''}> {output(node['content'], state)} </Spoiler> ) }, }, strong: { react: (node: Node, output: SM.ReactOutput, state: State) => { const oldInsideStrong = state.insideStrong state.insideStrong = true const ret = ( <Text type="BodySemibold" key={state.key} style={Styles.collapseStyles([ markdownStyles.boldStyle, state.insideEM && markdownStyles.italicStyle, state.styleOverride?.strong, ])} allowFontScaling={state.allowFontScaling} > {output(node['content'], state)} </Text> ) state.insideStrong = oldInsideStrong return ret }, }, text: SimpleMarkdown.defaultRules.text, } const passthroughForMarkdownType = Object.keys(reactComponentsForMarkdownType).reduce<{ [key: string]: SM.OutputRules<any> }>((obj, k) => { // keep special Array type if (k === 'Array') { obj[k] = reactComponentsForMarkdownType[k] } else { obj[k] = { react: (node: Node, output: SM.ReactOutput, state: State): React.ReactNode => typeof node['content'] !== 'object' ? SimpleMarkdown.defaultRules.text.react( {content: node['content'] as Array<Node>, type: 'text'}, output as SM.Output<string>, state ) : output(node['content'], state), } } return obj }, {}) export const bigEmojiOutput: SM.Output<React.ReactNode> = SimpleMarkdown.outputFor( { ...reactComponentsForMarkdownType, emoji: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <NativeEmoji style={state.styleOverride?.paragraph} emojiName={ //eslint-disable-next-line String(node['content']) } size={32} key={state.key} allowFontScaling={state.allowFontScaling} /> ), }, paragraph: { react: (node: Node, output: SM.ReactOutput, state: State) => { const oldInParagraph = state.inParagraph state.inParagraph = true const ret = ( <Text type="Body" key={state.key} style={markdownStyles.bigTextBlockStyle} allowFontScaling={state.allowFontScaling} > {output(node['content'], state)} </Text> ) state.inParagraph = oldInParagraph return ret }, }, }, 'react' ) export const previewOutput: SM.Output<React.ReactNode> = SimpleMarkdown.outputFor( { Array: SimpleMarkdown.defaultRules.Array, ...passthroughForMarkdownType, blockQuote: { react: (node: Node, output: SM.ReactOutput, state: State) => React.Children.toArray([ output([{content: '> ', type: 'text'}], state), output(node['content'], state), ]), }, codeBlock: { react: (node: Node, output: SM.ReactOutput, state: State) => React.Children.toArray([ output([{content: ' ', type: 'text'}], state), output(node['content'], state), ]), }, emoji: { react: (node: Node, output: SM.ReactOutput, state: State) => reactComponentsForMarkdownType.emoji.react(node, output, state), }, newline: { react: (_node: Node, _output: SM.ReactOutput, _state: State) => ' ', }, serviceDecoration: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <ServiceDecoration json={node['content'] as unknown as string} key={state.key} allowFontScaling={state.allowFontScaling} styleOverride={state.styleOverride} styles={markdownStyles} disableBigEmojis={true} disableEmojiAnimation={true} /> ), }, spoiler: { react: (node: Node, output: SM.ReactOutput, state: State) => { return ( <Spoiler key={state.key} context={state.context} content={node['raw'] ?? ''}> {output(node['content'], state)} </Spoiler> ) }, }, text: SimpleMarkdown.defaultRules.text, }, 'react' ) export const serviceOnlyOutput: SM.Output<React.ReactNode> = SimpleMarkdown.outputFor( { Array: SimpleMarkdown.defaultRules.Array, ...passthroughForMarkdownType, emoji: { react: (node: Node, output: SM.ReactOutput, state: State) => reactComponentsForMarkdownType.emoji.react(node, output, state), }, serviceDecoration: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <ServiceDecoration json={node['content'] as unknown as string} key={state.key} allowFontScaling={state.allowFontScaling} styleOverride={state.styleOverride} styles={markdownStyles} disableBigEmojis={true} disableEmojiAnimation={state.disallowAnimation ?? true} /> ), }, spoiler: { react: (node: Node, output: SM.ReactOutput, state: State) => { return ( <Spoiler key={state.key} context={state.context} content={node['raw'] ?? ''}> {output(node['content'], state)} </Spoiler> ) }, }, text: SimpleMarkdown.defaultRules.text, }, 'react' ) export const serviceOnlyNoWrapOutput: SM.Output<React.ReactNode> = SimpleMarkdown.outputFor( { Array: SimpleMarkdown.defaultRules.Array, ...Object.keys(reactComponentsForMarkdownType).reduce<{ [key: string]: SM.OutputRules<unknown> }>((obj, k) => { // keep special Array type if (k === 'Array') { obj[k] = reactComponentsForMarkdownType[k] } else { obj[k] = { react: () => null, } } return obj }, {}), emoji: { react: (node: Node, output: SM.ReactOutput, state: State) => reactComponentsForMarkdownType.emoji.react(node, output, state), }, paragraph: { react: (node: Node, output: SM.ReactOutput): React.ReactNode => output(node['content']), }, serviceDecoration: { react: (node: Node, _output: SM.ReactOutput, state: State) => ( <ServiceDecoration json={node['content'] as unknown as string} key={state.key} allowFontScaling={state.allowFontScaling} styleOverride={state.styleOverride} styles={markdownStyles} disableBigEmojis={true} disableEmojiAnimation={state.disallowAnimation ?? true} /> ), }, }, 'react' ) export const reactOutput: SM.Output<React.ReactNode> = SimpleMarkdown.outputFor( reactComponentsForMarkdownType, 'react' )