8729ccfcf5
- Each message is role="article"; collapsed messages (consecutive from one sender) now carry an aria-label with sender + time — previously a screen reader heard only the body with no attribution (the biggest a11y gap). Pure messageAriaLabel() reuses the existing time utils (+3 tests). - Editing a message announces "Editing message from <sender>" (ariaLabel threaded MessageEditor → CustomEditor; the main composer is unaffected). - System emoji get role="img" + aria-label from the shortcode; custom emoticons always have an accessible name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
656 lines
20 KiB
TypeScript
656 lines
20 KiB
TypeScript
import React, {
|
|
type JSX,
|
|
ComponentPropsWithoutRef,
|
|
ReactEventHandler,
|
|
Suspense,
|
|
lazy,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
DOMNode,
|
|
Element,
|
|
Text as DOMText,
|
|
HTMLReactParserOptions,
|
|
attributesToProps,
|
|
domToReact,
|
|
} from 'html-react-parser';
|
|
import { MatrixClient } from 'matrix-js-sdk';
|
|
import classNames from 'classnames';
|
|
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
|
|
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
|
|
import Linkify from 'linkify-react';
|
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
import { ChildNode } from 'domhandler';
|
|
import * as css from '../styles/CustomHtml.css';
|
|
import {
|
|
getMxIdLocalPart,
|
|
getCanonicalAliasRoomId,
|
|
isRoomAlias,
|
|
mxcUrlToHttp,
|
|
} from '../utils/matrix';
|
|
import { getMemberDisplayName } from '../utils/room';
|
|
import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex';
|
|
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
|
|
import { findAndReplace } from '../utils/findAndReplace';
|
|
import {
|
|
parseMatrixToRoom,
|
|
parseMatrixToRoomEvent,
|
|
parseMatrixToUser,
|
|
testMatrixTo,
|
|
} from './matrix-to';
|
|
import { onEnterOrSpace } from '../utils/keyboard';
|
|
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
|
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
|
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
|
|
import { splitMathSegments } from '../utils/mathParse';
|
|
|
|
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
|
|
|
// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
|
|
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
|
|
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));
|
|
|
|
/** Languages handled by the custom TDS tokenizer. */
|
|
const TDS_TOKENIZER_LANGS = new Set([
|
|
'js',
|
|
'javascript',
|
|
'ts',
|
|
'typescript',
|
|
'jsx',
|
|
'tsx',
|
|
'py',
|
|
'python',
|
|
'rs',
|
|
'rust',
|
|
]);
|
|
|
|
/**
|
|
* Renders a code string as an array of coloured <span> elements using the
|
|
* lightweight TDS tokenizer. Falls back to a plain text node when the
|
|
* language is not in the supported set.
|
|
*/
|
|
function renderTokenizedCode(code: string, lang: string): React.ReactNode {
|
|
const normalised = lang.toLowerCase().replace(/^language-/, '');
|
|
if (!TDS_TOKENIZER_LANGS.has(normalised)) return code;
|
|
|
|
const tokens = tokenize(code, normalised);
|
|
|
|
return tokens.map((tok, idx) => (
|
|
<span key={idx} style={tok.type !== 'plain' ? tokenStyle(tok.type) : undefined}>
|
|
{tok.text}
|
|
</span>
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Renders LaTeX via the lazily-loaded KaTeX component.
|
|
*
|
|
* `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
|
|
* `errorFallback` is shown if rendering fails outright — for the spec
|
|
* `data-mx-maths` path this is the element's original children (the spec
|
|
* fallback content); for the plain-text `$…$` path it is the raw source.
|
|
*/
|
|
const renderMath = (
|
|
latex: string,
|
|
displayMode: boolean,
|
|
suspenseFallback: React.ReactNode,
|
|
errorFallback: React.ReactNode,
|
|
): JSX.Element => (
|
|
<ErrorBoundary fallback={<>{errorFallback}</>}>
|
|
<Suspense fallback={<>{suspenseFallback}</>}>
|
|
<KaTeXMath latex={latex} displayMode={displayMode} />
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
|
|
|
|
export const LINKIFY_OPTS: LinkifyOpts = {
|
|
attributes: {
|
|
target: '_blank',
|
|
rel: 'noreferrer noopener',
|
|
},
|
|
validate: {
|
|
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
|
|
},
|
|
ignoreTags: ['span'],
|
|
};
|
|
|
|
export const makeMentionCustomProps = (
|
|
handleMentionClick?: ReactEventHandler<HTMLElement>,
|
|
content?: string,
|
|
): ComponentPropsWithoutRef<'a'> => ({
|
|
style: { cursor: 'pointer' },
|
|
target: '_blank',
|
|
rel: 'noreferrer noopener',
|
|
role: 'link',
|
|
tabIndex: handleMentionClick ? 0 : -1,
|
|
onKeyDown: handleMentionClick ? onEnterOrSpace(handleMentionClick) : undefined,
|
|
onClick: handleMentionClick,
|
|
children: content,
|
|
});
|
|
|
|
export const renderMatrixMention = (
|
|
mx: MatrixClient,
|
|
currentRoomId: string | undefined,
|
|
href: string,
|
|
customProps: ComponentPropsWithoutRef<'a'>,
|
|
) => {
|
|
const userId = parseMatrixToUser(href);
|
|
if (userId) {
|
|
const currentRoom = mx.getRoom(currentRoomId);
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({ highlight: mx.getUserId() === userId })}
|
|
data-mention-id={userId}
|
|
>
|
|
{`@${
|
|
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
|
|
}`}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
const matrixToRoom = parseMatrixToRoom(href);
|
|
if (matrixToRoom) {
|
|
const { roomIdOrAlias, viaServers } = matrixToRoom;
|
|
const mentionRoom = mx.getRoom(
|
|
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
|
|
);
|
|
|
|
const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({
|
|
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
|
|
})}
|
|
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
|
|
data-mention-via={viaServers?.join(',')}
|
|
>
|
|
{customProps.children ? customProps.children : fallbackContent}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
const matrixToRoomEvent = parseMatrixToRoomEvent(href);
|
|
if (matrixToRoomEvent) {
|
|
const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent;
|
|
const mentionRoom = mx.getRoom(
|
|
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
|
|
);
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({
|
|
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
|
|
})}
|
|
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
|
|
data-mention-event-id={eventId}
|
|
data-mention-via={viaServers?.join(',')}
|
|
>
|
|
{customProps.children
|
|
? customProps.children
|
|
: `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const factoryRenderLinkifyWithMention = (
|
|
mentionRender: (href: string) => JSX.Element | undefined,
|
|
): OptFn<(ir: IntermediateRepresentation) => any> => {
|
|
const render: OptFn<(ir: IntermediateRepresentation) => any> = ({
|
|
tagName,
|
|
attributes,
|
|
content,
|
|
}) => {
|
|
if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) {
|
|
const mention = mentionRender(tryDecodeURIComponent(attributes.href));
|
|
if (mention) return mention;
|
|
}
|
|
|
|
return <a {...attributes}>{content}</a>;
|
|
};
|
|
return render;
|
|
};
|
|
|
|
export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
|
findAndReplace(
|
|
text,
|
|
EMOJI_REG_G,
|
|
(match, pushIndex) => {
|
|
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
|
|
return (
|
|
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
|
<span
|
|
className={css.Emoticon()}
|
|
title={shortcode}
|
|
aria-label={shortcode || undefined}
|
|
role={shortcode ? 'img' : undefined}
|
|
>
|
|
{match[0]}
|
|
</span>
|
|
</span>
|
|
);
|
|
},
|
|
(txt) => txt,
|
|
);
|
|
|
|
export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
|
|
const pattern = highlights.map(sanitizeForRegex).join('|');
|
|
if (!pattern) return undefined;
|
|
return new RegExp(pattern, 'gi');
|
|
};
|
|
|
|
export const highlightText = (
|
|
regex: RegExp,
|
|
data: (string | JSX.Element)[],
|
|
): (string | JSX.Element)[] =>
|
|
data.flatMap((text) => {
|
|
if (typeof text !== 'string') return text;
|
|
|
|
return findAndReplace(
|
|
text,
|
|
regex,
|
|
(match, pushIndex) => (
|
|
<span key={`highlight-${pushIndex}`} className={css.highlightText}>
|
|
{match[0]}
|
|
</span>
|
|
),
|
|
(txt) => txt,
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
|
|
*
|
|
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
|
|
* @returns {string} The concatenated plain text content of all descendant text nodes.
|
|
*/
|
|
const extractTextFromChildren = (nodes: ChildNode[]): string => {
|
|
let text = '';
|
|
|
|
nodes.forEach((node) => {
|
|
if (node.type === 'text') {
|
|
text += node.data;
|
|
} else if (node instanceof Element && node.children) {
|
|
text += extractTextFromChildren(node.children);
|
|
}
|
|
});
|
|
|
|
return text;
|
|
};
|
|
|
|
export function CodeBlock({
|
|
children,
|
|
opts,
|
|
}: {
|
|
children: ChildNode[];
|
|
opts: HTMLReactParserOptions;
|
|
}) {
|
|
const code = children[0];
|
|
const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
|
|
const languageClass = attribs?.class;
|
|
const customLabel = attribs?.['data-label'];
|
|
const language =
|
|
languageClass && languageClass.startsWith('language-')
|
|
? languageClass.replace('language-', '')
|
|
: languageClass;
|
|
|
|
const LINE_LIMIT = 14;
|
|
const largeCodeBlock = useMemo(
|
|
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
|
|
[children],
|
|
);
|
|
|
|
const [expanded, setExpand] = useState(false);
|
|
const [copied, setCopied] = useTimeoutToggle();
|
|
|
|
const handleCopy = () => {
|
|
copyToClipboard(extractTextFromChildren(children));
|
|
setCopied();
|
|
};
|
|
|
|
const toggleExpand = () => {
|
|
setExpand(!expanded);
|
|
};
|
|
|
|
return (
|
|
<Text size="T300" as="pre" className={css.CodeBlock}>
|
|
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
|
|
<Box grow="Yes">
|
|
<Text size="L400" truncate>
|
|
{customLabel ?? language ?? 'Code'}
|
|
</Text>
|
|
</Box>
|
|
<Box shrink="No" gap="200">
|
|
<Chip
|
|
variant={copied ? 'Success' : 'Surface'}
|
|
fill="None"
|
|
radii="Pill"
|
|
onClick={handleCopy}
|
|
before={copied && <Icon size="50" src={Icons.Check} />}
|
|
>
|
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
|
</Chip>
|
|
{largeCodeBlock && (
|
|
<IconButton
|
|
size="300"
|
|
variant="SurfaceVariant"
|
|
outlined
|
|
radii="300"
|
|
onClick={toggleExpand}
|
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
>
|
|
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</Header>
|
|
<Scroll
|
|
style={{
|
|
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
|
|
paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
|
|
}}
|
|
direction="Both"
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
visibility="Hover"
|
|
hideTrack
|
|
>
|
|
<div id="code-block-content" className={css.CodeBlockInternal}>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</div>
|
|
</Scroll>
|
|
{largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
export const getReactCustomHtmlParser = (
|
|
mx: MatrixClient,
|
|
roomId: string | undefined,
|
|
params: {
|
|
linkifyOpts: LinkifyOpts;
|
|
highlightRegex?: RegExp;
|
|
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
|
|
handleMentionClick?: ReactEventHandler<HTMLElement>;
|
|
useAuthentication?: boolean;
|
|
},
|
|
): HTMLReactParserOptions => {
|
|
const opts: HTMLReactParserOptions = {
|
|
replace: (domNode) => {
|
|
if (domNode instanceof Element && 'name' in domNode) {
|
|
const { name, attribs, children, parent } = domNode;
|
|
const props = attributesToProps(attribs);
|
|
|
|
if (name === 'h1') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H2">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h2') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H3">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h3') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H4">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h4') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H4">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h5') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H5">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h6') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H6">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'p') {
|
|
return (
|
|
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'pre') {
|
|
return <CodeBlock opts={opts}>{children}</CodeBlock>;
|
|
}
|
|
|
|
if (name === 'blockquote') {
|
|
return (
|
|
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'ul') {
|
|
return (
|
|
<ul {...props} className={css.List}>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</ul>
|
|
);
|
|
}
|
|
if (name === 'ol') {
|
|
return (
|
|
<ol {...props} className={css.List}>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
if (name === 'code') {
|
|
if (parent && 'name' in parent && parent.name === 'pre') {
|
|
const codeReact = domToReact(children as unknown as DOMNode[], opts);
|
|
if (typeof codeReact === 'string') {
|
|
let lang: string | undefined =
|
|
typeof props.className === 'string' ? props.className : undefined;
|
|
if (lang === 'language-rs') lang = 'language-rust';
|
|
else if (lang === 'language-js') lang = 'language-javascript';
|
|
else if (lang === 'language-ts') lang = 'language-typescript';
|
|
|
|
// Use lightweight TDS tokenizer for supported languages to render
|
|
// coloured <span> elements with inline TDS CSS variable styles.
|
|
const normLang = (lang ?? '').toLowerCase().replace(/^language-/, '');
|
|
if (TDS_TOKENIZER_LANGS.has(normLang)) {
|
|
return (
|
|
<code {...props} className={lang}>
|
|
{renderTokenizedCode(codeReact, normLang)}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
|
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
|
<ReactPrism>
|
|
{(ref) => (
|
|
<code ref={ref} {...props} className={lang}>
|
|
{codeReact}
|
|
</code>
|
|
)}
|
|
</ReactPrism>
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
} else {
|
|
return (
|
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (name === 'a' && testMatrixTo(tryDecodeURIComponent(String(props.href)))) {
|
|
const content = children.find((child) => !(child instanceof DOMText))
|
|
? undefined
|
|
: children.map((c) => (c instanceof DOMText ? c.data : '')).join();
|
|
|
|
const mention = renderMatrixMention(
|
|
mx,
|
|
roomId,
|
|
tryDecodeURIComponent(String(props.href)),
|
|
makeMentionCustomProps(params.handleMentionClick, content),
|
|
);
|
|
|
|
if (mention) return mention;
|
|
}
|
|
|
|
if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
|
|
// Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
|
|
// (block for <div>, inline for <span>). On failure fall back to the
|
|
// element's existing children, which the spec defines as the fallback
|
|
// representation.
|
|
const latex = String(props['data-mx-maths']);
|
|
const displayMode = name === 'div';
|
|
const fallback = displayMode ? (
|
|
<div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
|
|
) : (
|
|
<span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
|
|
);
|
|
return renderMath(latex, displayMode, latex, fallback);
|
|
}
|
|
|
|
if (name === 'span' && 'data-mx-spoiler' in props) {
|
|
return (
|
|
<span
|
|
{...props}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any);
|
|
}}
|
|
onClick={params.handleSpoilerClick}
|
|
className={css.Spoiler()}
|
|
aria-label="Spoiler — click to reveal"
|
|
aria-pressed
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{domToReact(children as unknown as DOMNode[], opts)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (name === 'img') {
|
|
const htmlSrc = mxcUrlToHttp(mx, String(props.src), params.useAuthentication);
|
|
if (htmlSrc && String(props.src).startsWith('mxc://') === false) {
|
|
return (
|
|
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
|
|
{props.alt || props.title || htmlSrc}
|
|
</a>
|
|
);
|
|
}
|
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
|
const emoticonAlt =
|
|
(typeof props.alt === 'string' && props.alt) ||
|
|
(typeof props.title === 'string' && props.title) ||
|
|
'emoji';
|
|
return (
|
|
<span className={css.EmoticonBase}>
|
|
<span className={css.Emoticon()}>
|
|
<img
|
|
{...props}
|
|
alt={emoticonAlt}
|
|
className={css.EmoticonImg}
|
|
src={htmlSrc}
|
|
loading="lazy"
|
|
/>
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
|
|
}
|
|
}
|
|
|
|
if (domNode instanceof DOMText) {
|
|
const parentName =
|
|
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
|
|
const linkify = parentName !== 'code' && parentName !== 'a';
|
|
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
|
|
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
|
|
|
|
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
|
|
let jsx = scaleSystemEmoji(text);
|
|
if (params.highlightRegex) {
|
|
jsx = highlightText(params.highlightRegex, jsx);
|
|
}
|
|
if (linkify) {
|
|
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
|
}
|
|
return jsx;
|
|
};
|
|
|
|
if (mathAllowed) {
|
|
const segments = splitMathSegments(domNode.data);
|
|
if (segments.some((segment) => segment.type !== 'text')) {
|
|
return (
|
|
<>
|
|
{segments.map((segment, index) => {
|
|
if (segment.type === 'text') {
|
|
return (
|
|
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
|
|
);
|
|
}
|
|
const raw =
|
|
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
|
|
return (
|
|
<React.Fragment key={index}>
|
|
{renderMath(segment.value, segment.type === 'block', raw, raw)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
return renderTextChunk(domNode.data);
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
return opts;
|
|
};
|