diff --git a/src/components/IslandPath.tsx b/src/components/IslandPath.tsx index 1ecadfa..4c19d16 100644 --- a/src/components/IslandPath.tsx +++ b/src/components/IslandPath.tsx @@ -5,6 +5,7 @@ import type { HomeNode } from '@/api'; import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath'; import { iconFor } from '@/components/NodeIcon'; import { shade } from '@/lib/color'; +import { MathText } from '@/lib/MathText'; const NODE_SIZE = 76; const SPACING = 128; @@ -97,7 +98,7 @@ export const IslandPath = forwardRef= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left' } ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`} > - {node.title} + ); diff --git a/src/components/IslandSheet.tsx b/src/components/IslandSheet.tsx index ec52611..1f465a9 100644 --- a/src/components/IslandSheet.tsx +++ b/src/components/IslandSheet.tsx @@ -6,6 +6,7 @@ import { useAsync } from '@/lib/useAsync'; import { Sheet } from '@/components/Sheet'; import { ErrorState, PrimaryButton } from '@/components/ui'; import { toFa } from '@/lib/format'; +import { MathText } from '@/lib/MathText'; /** * برگه‌ی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درس‌ها @@ -41,7 +42,7 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? []; return ( - + : undefined}> {locked ? (
@@ -93,7 +94,7 @@ export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; c ) : ( )} - {lesson.title} + ))} diff --git a/src/lib/MathText.tsx b/src/lib/MathText.tsx index 34b7eaf..ae38505 100644 --- a/src/lib/MathText.tsx +++ b/src/lib/MathText.tsx @@ -8,6 +8,12 @@ import { Fragment } from 'react'; * می‌شد. هر تکه‌ای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپ‌به‌راست * قفل می‌شود؛ جمله‌ی فارسی دورش دست نمی‌خورد. * + * فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و + * از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو + * جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده + * با مختصاتِ رسم‌شده در Chrome روی همه‌ی سؤال‌ها و گزینه‌های بانک و چند جوابِ دستیار + * (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد. + * * آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده. */ // حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند. @@ -17,36 +23,67 @@ const RUN = new RegExp(`[^${LETTER}\\n]*[0-9۰-۹][^${LETTER}\\n]*`, 'g'); // نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند const EDGE_START = /^[\s،؛:!؟«»"'.=]+/; const EDGE_END = /[\s،؛:!؟«»"'.=]+$/; -const LTR = { unicodeBidi: 'bidi-override', direction: 'ltr' } as const; +// isolate، نه embedding: تکه‌ی قفل‌شده از بیرون یک واحدِ خنثی است. با bidi-overrideِ ساده +// تکه‌ها از بیرون «حرفِ چپ‌به‌راست» حساب می‌شدند و «،»ِ بینِ دو عضوِ فهرست را هم به +// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد. +const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const; export function MathText({ text }: { text: string }) { const out: React.ReactNode[] = []; + const depth = bracketDepth(text); let last = 0, i = 0; for (const m of text.matchAll(RUN)) { - const start = m.index ?? 0; - let run = m[0]; - // شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند. - const atLineStart = start === 0 || text[start - 1] === '\n'; - const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : ''; - const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? ''); - run = run.slice(lead.length); - const tail = EDGE_END.exec(run)?.[0] ?? ''; - run = run.slice(0, run.length - tail.length); - // «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بی‌جفت در لبه‌ی تکه مالِ جمله است: - // در «۱۰ − ۲ (با محاسبه…)» پرانتزِ باز جزوِ جمله‌ی فارسیِ بعدی است و اگر قفل شود - // به سمتِ غلط می‌رود. - const edge = trimUnpaired(run); - const head = lead + edge.head; - run = edge.run; - if (!run || !/[0-9۰-۹]/.test(run)) continue; - out.push(text.slice(last, start + head.length)); - out.push({run}); - last = start + head.length + run.length; + for (const [start, piece] of splitList(m[0], m.index ?? 0, depth)) { + let run = piece; + // شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند. + const atLineStart = start === 0 || text[start - 1] === '\n'; + const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : ''; + const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? ''); + run = run.slice(lead.length); + const tail = EDGE_END.exec(run)?.[0] ?? ''; + run = run.slice(0, run.length - tail.length); + // «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بی‌جفت در لبه‌ی تکه مالِ جمله است: + // در «۱۰ − ۲ (با محاسبه…)» پرانتزِ باز جزوِ جمله‌ی فارسیِ بعدی است و اگر قفل شود + // به سمتِ غلط می‌رود. + const edge = trimUnpaired(run); + const head = lead + edge.head; + run = edge.run; + if (!run || !/[0-9۰-۹]/.test(run)) continue; + out.push(text.slice(last, start + head.length)); + out.push({run}); + last = start + head.length + run.length; + } } out.push(text.slice(last)); return <>{out.map((n, j) => (typeof n === 'string' ? {n} : n))}; } +/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */ +function bracketDepth(text: string): number[] { + const depth: number[] = []; + let d = 0; + for (const ch of text) { + depth.push(d); + if ('([{'.includes(ch)) d++; + else if (')]}'.includes(ch)) d = Math.max(0, d - 1); + } + return depth; +} + +/** تکه را روی «،» و «؛»ِ بیرونِ پرانتز می‌شکند؛ جداکننده خودش بیرون می‌ماند. */ +function splitList(run: string, offset: number, depth: number[]): [number, string][] { + const pieces: [number, string][] = []; + let from = 0; + for (let k = 0; k < run.length; k++) { + if ((run[k] === '،' || run[k] === '؛') && !depth[offset + k]) { + pieces.push([offset + from, run.slice(from, k)]); + from = k + 1; + } + } + pieces.push([offset + from, run.slice(from)]); + return pieces; +} + const count = (s: string, ch: string) => s.split(ch).length - 1; /** @@ -55,11 +92,13 @@ const count = (s: string, ch: string) => s.split(ch).length - 1; */ function trimUnpaired(run: string): { head: string; run: string } { let head = ''; - while (/[(\[]\s*$/.test(run) && count(run, '(') + count(run, '[') > count(run, ')') + count(run, ']')) { - run = run.slice(0, run.length - /\s*[(\[]\s*$/.exec(run)![0].length); + const opens = (r: string) => count(r, '(') + count(r, '[') + count(r, '{'); + const closes = (r: string) => count(r, ')') + count(r, ']') + count(r, '}'); + while (/[(\[{]\s*$/.test(run) && opens(run) > closes(run)) { + run = run.slice(0, run.length - /\s*[(\[{]\s*$/.exec(run)![0].length); } - while (/^\s*[)\]]/.test(run) && count(run, ')') + count(run, ']') > count(run, '(') + count(run, '[')) { - const m = /^\s*[)\]]\s*/.exec(run)![0]; + while (/^\s*[)\]}]/.test(run) && closes(run) > opens(run)) { + const m = /^\s*[)\]}]\s*/.exec(run)![0]; head += m; run = run.slice(m.length); } diff --git a/src/screens/AskScreen.tsx b/src/screens/AskScreen.tsx index b26fc43..0172e7f 100644 --- a/src/screens/AskScreen.tsx +++ b/src/screens/AskScreen.tsx @@ -173,6 +173,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; } }} rows={1} + // جهتِ هر خط از اولین حرفش: «-3 + 5» هنگامِ تایپ چپ‌به‌راست می‌ماند. خطی که با + // فارسی شروع شود راست‌به‌چپ است و ریاضیِ وسطش در کادرِ تایپ ممکن است جابه‌جا دیده شود؛ + // در textarea نمی‌شود تکه‌ای را قفل کرد. پیامِ فرستاده‌شده از MathText رد می‌شود. + dir={input ? 'auto' : 'rtl'} + style={{ unicodeBidi: 'plaintext', textAlign: 'right' }} placeholder="سوالت رو بنویس…" className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint" /> diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 3976681..ebe0019 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -10,6 +10,7 @@ import { IslandSheet } from '@/components/IslandSheet'; import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui'; import { shade } from '@/lib/color'; import { toFa } from '@/lib/format'; +import { MathText } from '@/lib/MathText'; /** * خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با @@ -38,7 +39,7 @@ export function HomeScreen() { : !home.units.length ? 'هنوز درسی برای این پایه ثبت نشده.' : cont - ? <>سلام {name}! 👋 نوبتِ جزیره‌ی «{cont.node.title}»ـه. دکمه‌ی شروع رو بزن. + ? <>سلام {name}! 👋 نوبتِ جزیره‌ی «»ـه. دکمه‌ی شروع رو بزن. : <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی 🎉; const stats = [ diff --git a/src/screens/LessonScreen.tsx b/src/screens/LessonScreen.tsx index a8aa8af..86e72a0 100644 --- a/src/screens/LessonScreen.tsx +++ b/src/screens/LessonScreen.tsx @@ -22,7 +22,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: return ( : '…'} color={color} back mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }} @@ -30,7 +30,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: ref && ( {/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */} - {ref.node.title}{toFa(ref.node.lessonIds.length)} درس + {toFa(ref.node.lessonIds.length)} درس ) } @@ -46,7 +46,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
-

{data.introText}

+

diff --git a/src/screens/NotificationsScreen.tsx b/src/screens/NotificationsScreen.tsx index 198f793..2dd1751 100644 --- a/src/screens/NotificationsScreen.tsx +++ b/src/screens/NotificationsScreen.tsx @@ -5,6 +5,7 @@ import { useAsync } from '@/lib/useAsync'; import { useToast } from '@/components/Toast'; import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui'; import { relativeTime, toFa } from '@/lib/format'; +import { MathText } from '@/lib/MathText'; const STYLE: Record = { xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' }, @@ -83,7 +84,7 @@ export function NotificationsScreen() {
{n.title} - {n.body && {toFa(n.body)}} + {n.body && } {relativeTime(n.createdAt)} {!n.isRead && } diff --git a/src/screens/SupportScreen.tsx b/src/screens/SupportScreen.tsx index b1c0af2..4289bdd 100644 --- a/src/screens/SupportScreen.tsx +++ b/src/screens/SupportScreen.tsx @@ -6,6 +6,7 @@ import { useToast } from '@/components/Toast'; import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui'; import { friendlyError } from '@/lib/errors'; import { toFa } from '@/lib/format'; +import { MathText } from '@/lib/MathText'; const CATEGORIES = [ { id: 'learning', label: 'درس و سوال‌ها' }, @@ -165,7 +166,7 @@ function TicketCard({ summary }: { summary: TicketSummary }) { key={m.id} className={`whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-xs leading-6 ${m.sender === 'user' ? 'mr-8 bg-soft text-body' : 'ml-8 bg-[#EEF1FF] text-ink'}`} > - {m.text} +
))} {t && status !== 'closed' && ( diff --git a/src/screens/UnitScreen.tsx b/src/screens/UnitScreen.tsx index fb3a9a1..c332943 100644 --- a/src/screens/UnitScreen.tsx +++ b/src/screens/UnitScreen.tsx @@ -8,6 +8,7 @@ import { IslandPath } from '@/components/IslandPath'; import { IslandSheet } from '@/components/IslandSheet'; import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui'; import { toFa } from '@/lib/format'; +import { MathText } from '@/lib/MathText'; /** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */ export function UnitScreen({ unitId }: { unitId: string }) { @@ -34,7 +35,7 @@ export function UnitScreen({ unitId }: { unitId: string }) { : `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`} color={color} back mascot={{ char: companion, mood: progress.percent >= 50 ? 'happy' : 'idle', size: 72 }}