fix(rsvp): render RTL words whole so Arabic shapes correctly (#4630) (#4648)

The RSVP word window split each word into before/orp/after spans at the
ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word
by character index breaks letter shaping (letters stop connecting, some
slices render as notdef boxes) and the LTR layout reverses the visual
order, so e.g. علم showed as disconnected, out-of-order letters.

Detect RTL text and render the word as a single centered span — reusing
the existing CJK Highlight Word path — with dir="rtl" so the browser
shapes and orders it correctly, matching the context panel. ORP anchoring
is meaningless for unsplittable shaped scripts, so RTL always renders
whole; no new toggle.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-18 22:03:43 +08:00
committed by GitHub
parent ff96c6d3f7
commit be17654fc0
4 changed files with 111 additions and 4 deletions
@@ -4,7 +4,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import clsx from 'clsx';
import { Insets } from '@/types/misc';
import { RsvpState, RSVPController } from '@/services/rsvp';
import { containsCJK } from '@/services/rsvp/utils';
import { containsCJK, isRTLText } from '@/services/rsvp/utils';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { TOCItem } from '@/libs/document';
@@ -351,6 +351,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : '';
const wordAfter = currentWord ? currentWord.text.substring(currentWord.orpIndex + 1) : '';
const isCJKWord = currentWord ? containsCJK(currentWord.text) : false;
// RTL words (Arabic, Hebrew, …) must never be split into before/orp/after
// spans: slicing by character index breaks letter shaping and reverses the
// visual order. Render them whole instead, like CJK Highlight Word (#4630).
const isRTLWord = currentWord ? isRTLText(currentWord.text) : false;
const wordLetterSpacing = undefined;
const wordSideOffset = isCJKWord ? '0.45em' : '0.3em';
@@ -973,12 +977,16 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}}
>
{currentWord ? (
isCJKWord && highlightWholeWord ? (
// Whole-word mode: center the full CJK word and color every
// character, instead of anchoring a single focus character.
isRTLWord || (isCJKWord && highlightWholeWord) ? (
// Whole-word mode: center the full word and color it, instead
// of anchoring a single focus character. Used for CJK Highlight
// Word and always for RTL words, whose shaping/order would
// break if sliced into before/orp/after spans (#4630). dir=rtl
// restores correct letter order and connection for RTL.
<span
className='rsvp-word-whole relative z-10 font-bold'
style={{ color: effectiveOrpColor }}
dir={isRTLWord ? 'rtl' : undefined}
>
{currentWord.text}
</span>