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>
109 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
});
|