Files
MathApp_frontend/src/components/IslandPath.tsx
AliReza 0e56d9c8aa math in Persian text: lists read right-to-left, every content text goes through MathText, chat input picks its direction per line
Measured by rendering every question, option and several tutor answers in
Chrome and comparing the on-screen x of each number (2026-09-29):
- A list inside a sentence ('از −۳ به −۲، −۱، ۰ می‌رسیم', 'الگویِ ۲، ۵، ۸')
  was locked as one left-to-right run, so all 482 read backwards. '،' and
  '؛' outside brackets now split the run; sets and coordinates in brackets
  stay one run. Locked runs are isolates (isolate-override), otherwise the
  comma between two of them was still pulled left-to-right.
- Without MathText 704 of 931 minus signs and 602 of 983 expressions were
  misplaced; lesson and island titles, the lesson intro, unit blurb,
  notification bodies and ticket messages now go through it.
- The chat box takes its direction from each line's first letter, so a
  line of pure math stays left-to-right while typing. A line that starts
  in Persian can still show its math reordered inside the box; a textarea
  cannot isolate part of its text. The sent message renders correctly.

Four expressions with Persian words inside them ('f(x) = (ریشه دوم x) + ۵۰')
remain ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:46:18 +03:30

109 lines
5.3 KiB
TypeScript

import { forwardRef } from 'react';
import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react';
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;
const TOP = 70;
/**
* مسیرِ جزیره‌های یک فصل، مثلِ دولینگو. جزیره‌ی فعلی یک دکمه‌ی «شروع» بالای
* سرش دارد که مستقیم آزمون را باز می‌کند؛ زدنِ خودِ جزیره برگه‌ی جزئیات را.
*
* `currentRef` به جزیره‌ی فعلی وصل می‌شود تا خانه بتواند تا آنجا اسکرول کند.
*/
export const IslandPath = forwardRef<HTMLDivElement, {
nodes: HomeNode[];
color: string;
/** جزیره‌ای که باید «شروع» بخورد؛ null یعنی در این فصل نیست. */
currentId: string | null;
onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
const points = zigzagPositions(nodes.length, SPACING, TOP);
const height = points.length ? points[points.length - 1].y + 80 : 120;
return (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
{nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = node.id === currentId;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
ref={current ? currentRef : undefined}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<motion.button
type="button"
onClick={() => onStart(node)}
animate={{ y: [0, -5, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-[62px] left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-black text-white"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
>
<Play size={14} fill="white" /> {node.status === 'in_progress' ? 'ادامه' : 'شروع'}
<span className="absolute -bottom-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45" style={{ backgroundColor: color }} />
</motion.button>
<motion.div
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.06, 0.3] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-12px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => onOpen(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e7e6ef' : done ? color : '#fff',
border: current ? `5px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cfcedb' : done ? `0 7px 0 ${shade(color, -0.25)}` : `0 7px 0 ${shade(color, -0.25)}`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#a9a8b8]" />
) : done ? (
<Check size={32} color="white" strokeWidth={3.5} />
) : (
<Icon size={30} style={{ color }} strokeWidth={2.5} />
)}
{done && (
<Star size={16} className="absolute -right-1 -top-1 text-[#FFC53D]" fill="#FFC53D" />
)}
</button>
{/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمه‌ی «شروع» است
و اسمِ زیرِ جزیره‌ی قبلی زیرِ آن دکمه پنهان می‌شد. سمتی که جای بیشتری دارد. */}
<div
className={`absolute top-1/2 w-28 -translate-y-1/2 text-[11px] font-bold leading-4 ${
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
>
<span className="line-clamp-2"><MathText text={node.title} /></span>
</div>
</motion.div>
);
})}
</div>
);
});