MathText display: formula in the middle of a Persian sentence gets its own centered line (quiz and lessons)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 20:24:55 +03:30
parent 3a0192518c
commit 941e8245f1
4 changed files with 21 additions and 7 deletions

View File

@@ -33,7 +33,15 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد. // سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد.
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const; const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
export function MathText({ text: raw }: { text: string }) { // فرمولِ «واقعی» (نه عددِ تنها): نمادِ رابطه یا عمل دارد و دستِ‌کم چهار نویسه است.
const FORMULA = /[=<>≤≥≠∈∉⊂⊆∪∩+×÷^√∑∫]|[0-9۰-۹A-Za-z)][\s]*[−\-/][\s]*[0-9۰-۹A-Za-z(]/;
/**
* `display`: فرمولِ وسطِ جمله‌ی فارسی در خطِ جدا و وسط‌چین می‌نشیند (سوال‌ها و درس‌ها)، تا
* چشم وسطِ فرمول به خطِ بعد نیفتد. عددِ تنها و عبارتِ کوتاهِ بدونِ نمادِ عمل داخلِ جمله می‌ماند.
* آزموده نشده: صفحه‌ی واقعیِ اپ در Chrome و Safari؛ فقط منطقِ تشخیص با نمونه‌های بانک سنجیده شد.
*/
export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) {
const text = plainPersian(plainMath(raw)); const text = plainPersian(plainMath(raw));
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
const depth = bracketDepth(text); const depth = bracketDepth(text);
@@ -56,7 +64,13 @@ export function MathText({ text: raw }: { text: string }) {
run = edge.run; run = edge.run;
if (!run || !HAS_MATH.test(run)) continue; if (!run || !HAS_MATH.test(run)) continue;
out.push(text.slice(last, start + head.length)); out.push(text.slice(last, start + head.length));
const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length);
const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after);
if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) {
out.push(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
} else {
out.push(<span key={i++} style={LTR}>{run}</span>); out.push(<span key={i++} style={LTR}>{run}</span>);
}
last = start + head.length + run.length; last = start + head.length + run.length;
} }
} }

View File

@@ -28,17 +28,17 @@ export function parseNumbered(text: string): { lead: string; items: string[] } |
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) { export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
const list = parseNumbered(text); const list = parseNumbered(text);
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} /></p>; if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} display /></p>;
return ( return (
<div className={className}> <div className={className}>
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} /></p>} {list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} display /></p>}
<ol className="flex flex-col gap-2.5"> <ol className="flex flex-col gap-2.5">
{list.items.map((item, i) => ( {list.items.map((item, i) => (
<li key={i} className="flex gap-2.5"> <li key={i} className="flex gap-2.5">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}> <span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)} {toFa(i + 1)}
</span> </span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} /></span> <span className="flex-1 whitespace-pre-line"><MathText text={item} display /></span>
</li> </li>
))} ))}
</ol> </ol>

View File

@@ -81,7 +81,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}> <span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)} {toFa(i + 1)}
</span> </span>
<span className="flex-1 whitespace-pre-line"><MathText text={step} />{figs('step', i)}</span> <span className="flex-1 whitespace-pre-line"><MathText text={step} display />{figs('step', i)}</span>
</li> </li>
))} ))}
</ol> </ol>

View File

@@ -345,7 +345,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span> <span className="text-xs font-medium text-muted">سوال {toFa(index + 1)}</span>
</div> </div>
<h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} /></h2> <h2 className="whitespace-pre-line text-base font-bold leading-8 text-ink"><MathText text={question.prompt} display /></h2>
{question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null} {question.figure ? <div className="mt-3"><Diagram spec={question.figure} /></div> : null}
</div> </div>