feat: render rich formulas and signed question media

This commit is contained in:
Codex
2026-06-30 00:42:52 +08:00
parent 4a13715f29
commit db86fa3705
10 changed files with 243 additions and 29 deletions

View File

@@ -11,7 +11,9 @@
"dev:h5": "taro build --type h5 --watch",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {},
"dependencies": {
"katex": "^0.17.0"
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/preset-react": "^7.29.7",

View File

@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import 'katex/dist/katex.min.css';
import './app.css';
export default function App({ children }: PropsWithChildren) {

View File

@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Taro from '@tarojs/taro';
import { Image, Text, View } from '@tarojs/components';
import { Image, RichText, Text, View } from '@tarojs/components';
import { signAssetPreview, type AssetWatermarkContext, type SignedAssetLink } from '@/services/catalog';
import './rich-content.css';
type InlineToken =
@@ -13,7 +15,7 @@ type RichBlock =
| { type: 'heading'; level: number; tokens: InlineToken[] }
| { type: 'formula'; text: string }
| { type: 'code'; language?: string; text: string }
| { type: 'image'; url: string; alt: string }
| { type: 'image'; url?: string; assetId?: string; alt: string }
| { type: 'blocked-image'; alt: string }
| { type: 'table'; header: string[]; align: string[]; rows: string[][] };
@@ -27,6 +29,18 @@ export interface RichContentProps {
}
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
const ASSET_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface SignedMediaState {
link?: SignedAssetLink;
watermark?: AssetWatermarkContext;
title?: string | null;
error?: string;
loading?: boolean;
}
type KatexModule = typeof import('katex');
let katexModulePromise: Promise<KatexModule> | null = null;
function classNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
@@ -95,6 +109,27 @@ export function normalizeSafeMediaUrl(value: unknown) {
return null;
}
function normalizeAssetId(value: unknown) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw) return null;
if (ASSET_ID_RE.test(raw)) return raw;
const byScheme = raw.match(/^(?:asset|content_asset):([0-9a-f-]{36})$/i);
if (byScheme && ASSET_ID_RE.test(byScheme[1])) return byScheme[1];
const byPath = raw.match(/^\/(?:asset|content-assets|content_assets)\/([0-9a-f-]{36})$/i);
if (byPath && ASSET_ID_RE.test(byPath[1])) return byPath[1];
return null;
}
function normalizeImageSource(value: unknown): { url?: string; assetId?: string } | null {
const assetId = normalizeAssetId(value);
if (assetId) return { assetId };
const url = normalizeSafeMediaUrl(value);
return url ? { url } : null;
}
function splitTableCells(line: string) {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(cell => cell.trim());
@@ -150,9 +185,9 @@ function appendParagraphWithImages(blocks: RichBlock[], rawText: string) {
const index = match.index || 0;
const before = text.slice(cursor, index).trim();
if (before) blocks.push({ type: 'paragraph', tokens: parseInline(before) });
const url = normalizeSafeMediaUrl(match[2]);
if (url) {
blocks.push({ type: 'image', url, alt: match[1] || '图片' });
const source = normalizeImageSource(match[2]);
if (source) {
blocks.push({ type: 'image', ...source, alt: match[1] || '图片' });
} else {
blocks.push({ type: 'blocked-image', alt: match[1] || '图片' });
}
@@ -250,13 +285,58 @@ function parseBlocks(input: unknown, mediaUrls: RichContentProps['mediaUrls'] =
flushParagraph();
for (const mediaUrl of mediaUrls || []) {
const url = normalizeSafeMediaUrl(mediaUrl);
if (url) blocks.push({ type: 'image', url, alt: '题目图片' });
const source = normalizeImageSource(mediaUrl);
if (source) blocks.push({ type: 'image', ...source, alt: '题目图片' });
}
return blocks;
}
function loadKatex() {
if (!katexModulePromise) {
katexModulePromise = import('katex');
}
return katexModulePromise;
}
function renderFormulaHtml(katex: KatexModule, text: string, displayMode = false) {
return katex.default.renderToString(text, {
displayMode,
throwOnError: false,
trust: false,
strict: 'warn',
output: 'html',
});
}
function FormulaView({ text, display = false, block = false }: { text: string; display?: boolean; block?: boolean }) {
const [html, setHtml] = useState('');
useEffect(() => {
let active = true;
loadKatex()
.then(katex => {
if (!active) return;
try {
setHtml(renderFormulaHtml(katex, text, display || block));
} catch (_error) {
setHtml('');
}
})
.catch(() => {
if (active) setHtml('');
});
return () => {
active = false;
};
}, [text, display, block]);
if (html && block) return <View className='rich-formula-block rendered'><RichText nodes={html} /></View>;
if (html) return <RichText className={display ? 'rich-formula-rendered display' : 'rich-formula-rendered'} nodes={html} />;
if (block) return <View className='rich-formula-block'><Text>{text}</Text></View>;
return <Text className={display ? 'rich-formula-inline display' : 'rich-formula-inline'}>{text}</Text>;
}
function renderInline(token: InlineToken, index: number) {
if (token.type === 'bold') {
return <Text key={index} className='rich-bold'>{token.text}</Text>;
@@ -265,7 +345,7 @@ function renderInline(token: InlineToken, index: number) {
return <Text key={index} className='rich-code-inline'>{token.text}</Text>;
}
if (token.type === 'formula') {
return <Text key={index} className={token.display ? 'rich-formula-inline display' : 'rich-formula-inline'}>{token.text}</Text>;
return <FormulaView key={index} text={token.text} display={token.display} />;
}
return <Text key={index}>{token.text}</Text>;
}
@@ -274,8 +354,51 @@ function preview(url: string) {
Taro.previewImage({ current: url, urls: [url] }).catch(() => undefined);
}
function assetIdsFromBlocks(blocks: RichBlock[]) {
return Array.from(new Set(blocks.flatMap(block => (block.type === 'image' && block.assetId ? [block.assetId] : []))));
}
export default function RichContent({ content, className, compact = false, muted = false, mediaUrls, emptyText }: RichContentProps) {
const blocks = parseBlocks(content, mediaUrls);
const blocks = useMemo(() => parseBlocks(content, mediaUrls), [content, mediaUrls]);
const assetIds = useMemo(() => assetIdsFromBlocks(blocks), [blocks]);
const [signedMedia, setSignedMedia] = useState<Record<string, SignedMediaState>>({});
const requestedAssetIds = useRef(new Set<string>());
useEffect(() => {
if (!assetIds.length) return;
let active = true;
for (const assetId of assetIds) {
if (requestedAssetIds.current.has(assetId)) continue;
requestedAssetIds.current.add(assetId);
setSignedMedia(current => ({ ...current, [assetId]: { ...current[assetId], loading: true } }));
signAssetPreview(assetId)
.then(payload => {
if (!active) return;
setSignedMedia(current => ({
...current,
[assetId]: {
link: payload.preview,
watermark: payload.watermark,
title: payload.item?.title || payload.item?.fileName || null,
loading: false,
},
}));
})
.catch(error => {
if (!active) return;
setSignedMedia(current => ({
...current,
[assetId]: {
error: error instanceof Error ? error.message : '图片签名失败',
loading: false,
},
}));
});
}
return () => {
active = false;
};
}, [assetIds.join('|')]);
if (!blocks.length) {
return emptyText ? <Text className='rich-empty'>{emptyText}</Text> : null;
@@ -299,7 +422,7 @@ export default function RichContent({ content, className, compact = false, muted
);
}
if (block.type === 'formula') {
return <View key={index} className='rich-formula-block'><Text>{block.text}</Text></View>;
return <FormulaView key={index} text={block.text} block />;
}
if (block.type === 'code') {
return (
@@ -310,10 +433,25 @@ export default function RichContent({ content, className, compact = false, muted
);
}
if (block.type === 'image') {
const signed = block.assetId ? signedMedia[block.assetId] : undefined;
const imageUrl = block.url || signed?.link?.url;
if (block.assetId && signed?.error) {
return <View key={index} className='rich-blocked-image'>{signed.error}</View>;
}
if (block.assetId && !imageUrl) {
return <View key={index} className='rich-image-pending'>...</View>;
}
if (!imageUrl) {
return <View key={index} className='rich-blocked-image'>{block.alt}</View>;
}
return (
<View key={index} className='rich-image-wrap' onClick={() => preview(block.url)}>
<Image className='rich-image' src={block.url} mode='widthFix' />
{block.alt ? <Text className='rich-image-caption'>{block.alt}</Text> : null}
<View key={index} className='rich-image-wrap' onClick={() => preview(imageUrl)}>
<Image className='rich-image' src={imageUrl} mode='widthFix' />
{block.alt || signed?.watermark?.traceId ? (
<Text className='rich-image-caption'>
{block.alt || signed?.title || '题目图片'}{signed?.watermark?.traceId ? ` · ${signed.watermark.traceId}` : ''}
</Text>
) : null}
</View>
);
}

View File

@@ -85,10 +85,23 @@
word-break: break-all;
}
.rich-formula-rendered {
display: inline-flex;
max-width: 100%;
margin: 0 4px;
overflow-x: auto;
vertical-align: middle;
}
.rich-formula-inline.display {
display: flex;
}
.rich-formula-rendered.display {
display: flex;
margin: 10px 0;
}
.rich-formula-block,
.rich-code-block {
min-width: 0;
@@ -105,6 +118,23 @@
word-break: break-word;
}
.rich-formula-block.rendered {
font-family: inherit;
white-space: normal;
}
.rich-formula-block .katex-display {
margin: 0;
overflow-x: auto;
overflow-y: hidden;
}
.rich-formula-block .katex,
.rich-formula-rendered .katex {
max-width: 100%;
font-size: 1.08em;
}
.rich-code-language {
display: block;
margin-bottom: 10px;
@@ -156,6 +186,16 @@
word-break: break-word;
}
.rich-image-pending {
padding: 18px;
border: 1px dashed #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
font-size: 22px;
line-height: 1.45;
}
.rich-table {
display: block;
width: 100%;