forked from akai/readest
fix(translate): align RTL translated text to the start (#4844)
Inline translation wrappers set lang but never dir, so RTL target languages (Arabic, Hebrew, Persian, etc.) inherited the source document's LTR base direction. Justified text then pushed its last line to the LTR start (left) instead of the RTL start (right). Derive the wrapper's dir from the target language via getDirFromLanguage so justified RTL translations align to the start. Extract the node construction into createTranslationTargetNode to make the behavior unit-testable. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTranslationTargetNode } from '@/app/reader/hooks/useTextTranslation';
|
||||
|
||||
describe('createTranslationTargetNode', () => {
|
||||
it('sets dir="rtl" on the wrapper for an RTL target language', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'مرحبا بالعالم',
|
||||
lang: 'ar',
|
||||
targetBlockClassName: 'translation-target-block',
|
||||
hidden: false,
|
||||
widthLineBreak: false,
|
||||
});
|
||||
|
||||
expect(wrapper.getAttribute('lang')).toBe('ar');
|
||||
expect(wrapper.getAttribute('dir')).toBe('rtl');
|
||||
});
|
||||
|
||||
it('sets dir="rtl" for a region-qualified RTL language (e.g. ar-EG)', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'مرحبا',
|
||||
lang: 'ar-EG',
|
||||
targetBlockClassName: 'translation-target-block',
|
||||
hidden: false,
|
||||
widthLineBreak: false,
|
||||
});
|
||||
|
||||
expect(wrapper.getAttribute('dir')).toBe('rtl');
|
||||
});
|
||||
|
||||
it('sets dir="auto" on the wrapper for a non-RTL target language', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'Hello world',
|
||||
lang: 'en',
|
||||
targetBlockClassName: 'translation-target-block',
|
||||
hidden: false,
|
||||
widthLineBreak: false,
|
||||
});
|
||||
|
||||
expect(wrapper.getAttribute('dir')).toBe('auto');
|
||||
});
|
||||
|
||||
it('builds the expected nested structure with the translated text', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'مرحبا بالعالم',
|
||||
lang: 'ar',
|
||||
targetBlockClassName: 'translation-target-toc',
|
||||
hidden: false,
|
||||
widthLineBreak: false,
|
||||
});
|
||||
|
||||
expect(wrapper.classList.contains('translation-target')).toBe(true);
|
||||
expect(wrapper.getAttribute('translation-element-mark')).toBe('1');
|
||||
const block = wrapper.querySelector('.translation-target-toc');
|
||||
expect(block).not.toBeNull();
|
||||
const inner = wrapper.querySelector('.target-inner');
|
||||
expect(inner?.textContent).toBe('مرحبا بالعالم');
|
||||
});
|
||||
|
||||
it('marks the wrapper hidden when hidden is true', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'مرحبا',
|
||||
lang: 'ar',
|
||||
targetBlockClassName: 'translation-target-block',
|
||||
hidden: true,
|
||||
widthLineBreak: false,
|
||||
});
|
||||
|
||||
expect(wrapper.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('prepends a <br> when widthLineBreak is true', () => {
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText: 'مرحبا',
|
||||
lang: 'ar',
|
||||
targetBlockClassName: 'translation-target-block',
|
||||
hidden: false,
|
||||
widthLineBreak: true,
|
||||
});
|
||||
|
||||
expect(wrapper.firstChild?.nodeName).toBe('BR');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,44 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { walkTextNodes } from '@/utils/walk';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { getDirFromLanguage } from '@/utils/rtl';
|
||||
|
||||
export const createTranslationTargetNode = ({
|
||||
translatedText,
|
||||
lang,
|
||||
targetBlockClassName,
|
||||
hidden,
|
||||
widthLineBreak,
|
||||
}: {
|
||||
translatedText: string;
|
||||
lang: string;
|
||||
targetBlockClassName: string;
|
||||
hidden: boolean;
|
||||
widthLineBreak: boolean;
|
||||
}) => {
|
||||
const wrapper = document.createElement('font');
|
||||
wrapper.className = `translation-target ${hidden ? 'hidden' : ''}`;
|
||||
wrapper.setAttribute('translation-element-mark', '1');
|
||||
wrapper.setAttribute('lang', lang);
|
||||
// Set the base direction from the target language so justified RTL text
|
||||
// (e.g. Arabic) aligns to the start (right) instead of inheriting the
|
||||
// source document's LTR direction.
|
||||
wrapper.setAttribute('dir', getDirFromLanguage(lang));
|
||||
if (widthLineBreak) {
|
||||
wrapper.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
const blockWrapper = document.createElement('font');
|
||||
blockWrapper.className = `translation-target ${targetBlockClassName}`;
|
||||
|
||||
const inner = document.createElement('font');
|
||||
inner.className = 'translation-target target-inner target-inner-theme-none';
|
||||
inner.textContent = translatedText;
|
||||
|
||||
blockWrapper.appendChild(inner);
|
||||
wrapper.appendChild(blockWrapper);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
export function useTextTranslation(
|
||||
bookKey: string,
|
||||
@@ -260,23 +298,13 @@ export function useTextTranslation(
|
||||
const translatedText = translated[0];
|
||||
if (!translatedText || text === translatedText) return;
|
||||
|
||||
const wrapper = document.createElement('font');
|
||||
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
|
||||
wrapper.setAttribute('translation-element-mark', '1');
|
||||
wrapper.setAttribute('lang', targetLang || getLocale());
|
||||
if (widthLineBreak) {
|
||||
wrapper.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
const blockWrapper = document.createElement('font');
|
||||
blockWrapper.className = `translation-target ${targetBlockClassName}`;
|
||||
|
||||
const inner = document.createElement('font');
|
||||
inner.className = 'translation-target target-inner target-inner-theme-none';
|
||||
inner.textContent = translatedText;
|
||||
|
||||
blockWrapper.appendChild(inner);
|
||||
wrapper.appendChild(blockWrapper);
|
||||
const wrapper = createTranslationTargetNode({
|
||||
translatedText,
|
||||
lang: targetLang || getLocale(),
|
||||
targetBlockClassName,
|
||||
hidden: !enabled.current,
|
||||
widthLineBreak,
|
||||
});
|
||||
|
||||
if (el.querySelector('.translation-target')) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user