forked from wangziqi/gongxue-base
feat: render rich formulas and signed question media
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import './app.css';
|
||||
|
||||
export default function App({ children }: PropsWithChildren) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user