impr(tape mode): support RTL languages (@NadAlaba) (#5748)

### Description

1. Support RTL in tape mode:
- In `scrollTape()`: flip the sign of `#words.margin-left` and add
`.word.margin-right` to center first letter in RTL.
- In `Caret.getTargetPositionLeft()`: flip the direction of tapeMargin
in RTL.
   - Remove restriction on RTL tape mode from test-logic.ts.

2. Support zero-width characters in tape mode:
- Subtract the width of the last letter that has a positive width if the
current letter has a zero width (e.g, diacritics). This is needed when
calculation is based on letter widths instead of letter position, which
is done in caret.ts when tape=word, and in `scrollTape()` when
tape=letter.

3. Remove the width change of `#words` in tape mode to 200vw because
it's not needed anymore now that we're using `white-space: nowrap`:
- Also adjust the limit of `.afterNewline.margin-left` to be 3 times the
new width of `#words` which is now equal to `#wordsWrapper.width` by
default.

4. Make `.word.height` in `.withLigature` langs similar to their height
in english:
- Imitate the appearance and behavior of `inline-block` `<letter>`s in
`.withLigatures` lanuages. These languages make the display of
`<letter>` elements `inline` in order to allow the joining of letters.
However, this causes `<letter>`'s `border-bottom` to be ignored, which
changes `.word` height, so we add a `padding-bottom` to the `.word` in
that case.
- Also, `inline` `<letter>`s overflow the `#wordWrapper` without
wrapping (e.g, when `maxLineWidth` = 20ch and we type 30 letters), so we
add the property `overflow-wrap: anywhere`, but we don't allow `.hints`
to inherit this property.
- P.S, it is necessary that all `.word`s have the same height (with and
without ligatures), because we now set the height of `.beforeNewline`s
in css, and we depend on these elements to have the same height as
`.word`s so that the user won't feel a vertical shift in lines in tape
mode.

5. Animate turning off tape mode in `updateWordsMargin()` if
`SmoothLineScroller` is on.

6. Block removing words at the first call of `scrollTape()`:
- Because the inline style of `#words.margin-left` may be negative when
restarting the test, making `scrollTape()` start when the first word is
overflown to the left, which makes `scrollTape()` remove that word (this
bug affects LTR and RTL langs).

closes #3923

---------

Co-authored-by: Jack <jack@monkeytype.com>
This commit is contained in:
Nad Alaba 2025-06-18 13:02:43 +03:00 committed by GitHub
parent 85543ffa19
commit 5ca47e116b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 42 deletions

View file

@ -296,7 +296,6 @@
&.tape {
display: block;
white-space: nowrap;
width: 200vw;
.word {
margin: 0.25em 0.6em 0.25em 0;
display: inline-block;
@ -314,8 +313,21 @@
}
}
&.withLigatures {
letter {
display: inline;
.word {
overflow-wrap: anywhere;
padding-bottom: 0.05em; // compensate for letter border
.hints {
overflow-wrap: initial;
}
letter {
display: inline;
}
}
.beforeNewline {
border-top: unset;
padding-bottom: 0.05em;
}
}
&.blurred {
@ -743,8 +755,17 @@
}
}
&.withLigatures {
letter {
display: inline;
.word {
overflow-wrap: anywhere;
padding-bottom: 2px; // compensate for letter border
.hints {
overflow-wrap: initial;
}
letter {
display: inline;
}
}
}
}

View file

@ -107,7 +107,11 @@ function getTargetPositionLeft(
} else {
const wordsWrapperWidth =
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
const tapeMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
const tapeMargin =
wordsWrapperWidth *
(isLanguageRightToLeft
? 1 - Config.tapeMargin / 100
: Config.tapeMargin / 100);
result =
tapeMargin -
@ -115,11 +119,17 @@ function getTargetPositionLeft(
if (Config.tapeMode === "word" && inputLen > 0) {
let currentWordWidth = 0;
let lastPositiveLetterWidth = 0;
for (let i = 0; i < inputLen; i++) {
if (invisibleExtraLetters && i >= wordLen) break;
currentWordWidth +=
$(currentWordNodeList[i] as HTMLElement).outerWidth(true) ?? 0;
const letterOuterWidth =
$(currentWordNodeList[i] as Element).outerWidth(true) ?? 0;
currentWordWidth += letterOuterWidth;
if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth;
}
// if current letter has zero width move the caret to previous positive width letter
if ($(currentWordNodeList[inputLen] as Element).outerWidth(true) === 0)
currentWordWidth -= lastPositiveLetterWidth;
if (isLanguageRightToLeft) currentWordWidth *= -1;
result += currentWordWidth;
}

View file

@ -455,13 +455,6 @@ export async function init(): Promise<void | null> {
}
}
if (Config.tapeMode !== "off" && language.rightToLeft) {
Notifications.add("This language does not support tape mode.", 0, {
important: true,
});
UpdateConfig.setTapeMode("off");
}
const allowLazyMode = !language.noLazyMode || Config.mode === "custom";
if (Config.lazyMode && !allowLazyMode) {
rememberLazyMode = true;

View file

@ -413,11 +413,10 @@ export function showWords(): void {
}
updateActiveElement(undefined, true);
updateWordWrapperClasses();
setTimeout(() => {
void Caret.updatePosition();
}, 125);
updateWordWrapperClasses();
}
export function appendEmptyWordElement(): void {
@ -598,12 +597,32 @@ export function updateWordsWrapperHeight(force = false): void {
function updateWordsMargin(): void {
if (Config.tapeMode !== "off") {
void scrollTape();
void scrollTape(true);
} else {
setTimeout(() => {
$("#words").css("margin-left", "unset");
$("#words .afterNewline").css("margin-left", "unset");
}, 0);
const wordsEl = document.getElementById("words") as HTMLElement;
const afterNewlineEls =
wordsEl.querySelectorAll<HTMLElement>(".afterNewline");
if (Config.smoothLineScroll) {
const jqWords = $(wordsEl);
jqWords.stop("leftMargin", true, false).animate(
{
marginLeft: 0,
},
{
duration: SlowTimer.get() ? 0 : 125,
queue: "leftMargin",
}
);
jqWords.dequeue("leftMargin");
$(afterNewlineEls)
.stop(true, false)
.animate({ marginLeft: 0 }, SlowTimer.get() ? 0 : 125);
} else {
wordsEl.style.marginLeft = `0`;
for (const afterNewline of afterNewlineEls) {
afterNewline.style.marginLeft = `0`;
}
}
}
}
@ -816,11 +835,14 @@ function getNlCharWidth(
return nlChar.offsetWidth + letterMargin;
}
export async function scrollTape(): Promise<void> {
export async function scrollTape(noRemove = false): Promise<void> {
if (ActivePage.get() !== "test" || resultVisible) return;
await centeringActiveLine;
const currentLang = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLang.rightToLeft;
// index of the active word in the collection of .word elements
const wordElementIndex = TestState.activeWordIndex - activeWordElementOffset;
const wordsWrapperWidth = (
@ -898,7 +920,10 @@ export async function scrollTape(): Promise<void> {
const wordOuterWidth = $(child).outerWidth(true) ?? 0;
const forWordLeft = Math.floor(child.offsetLeft);
const forWordWidth = Math.floor(child.offsetWidth);
if (forWordLeft < 0 - forWordWidth) {
if (
(!isLanguageRTL && forWordLeft < 0 - forWordWidth) ||
(isLanguageRTL && forWordLeft > wordsWrapperWidth)
) {
toRemove.push(child);
widthRemoved += wordOuterWidth;
wordsToRemoveCount++;
@ -912,15 +937,20 @@ export async function scrollTape(): Promise<void> {
fullLineWidths -= nlCharWidth + wordRightMargin;
if (i < activeWordIndex) wordsWidthBeforeActive = fullLineWidths;
if (fullLineWidths < wordsEl.offsetWidth) {
/** words that are wider than limit can cause a barely visible bottom line shifting,
* increase limit if that ever happens, but keep the limit because browsers hate
* ridiculously wide margins which may cause the words to not be displayed
*/
const limit = 3 * wordsEl.offsetWidth;
if (fullLineWidths < limit) {
afterNewlinesNewMargins.push(fullLineWidths);
widthRemovedFromLine.push(widthRemoved);
} else {
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
afterNewlinesNewMargins.push(limit);
widthRemovedFromLine.push(widthRemoved);
if (i < lastElementIndex) {
// for the second .afterNewline after active word
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
afterNewlinesNewMargins.push(limit);
widthRemovedFromLine.push(widthRemoved);
}
break;
@ -929,7 +959,7 @@ export async function scrollTape(): Promise<void> {
}
/* remove overflown elements */
if (toRemove.length > 0) {
if (toRemove.length > 0 && !noRemove) {
activeWordElementOffset += wordsToRemoveCount;
for (const el of toRemove) el.remove();
for (let i = 0; i < widthRemovedFromLine.length; i++) {
@ -940,30 +970,40 @@ export async function scrollTape(): Promise<void> {
currentLineIndent - (widthRemovedFromLine[i] ?? 0)
}px`;
}
if (isLanguageRTL) widthRemoved *= -1;
const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0;
wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`;
}
/* calculate current word width to add to #words margin */
let currentWordWidth = 0;
if (Config.tapeMode === "letter") {
if (TestInput.input.current.length > 0) {
const letters = activeWordEl.querySelectorAll("letter");
for (let i = 0; i < TestInput.input.current.length; i++) {
const letter = letters[i] as HTMLElement;
if (
(Config.blindMode || Config.hideExtraLetters) &&
letter.classList.contains("extra")
) {
continue;
}
currentWordWidth += $(letter).outerWidth(true) ?? 0;
const inputLength = TestInput.input.current.length;
if (Config.tapeMode === "letter" && inputLength > 0) {
const letters = activeWordEl.querySelectorAll("letter");
let lastPositiveLetterWidth = 0;
for (let i = 0; i < inputLength; i++) {
const letter = letters[i] as HTMLElement;
if (
(Config.blindMode || Config.hideExtraLetters) &&
letter.classList.contains("extra")
) {
continue;
}
const letterOuterWidth = $(letter).outerWidth(true) ?? 0;
currentWordWidth += letterOuterWidth;
if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth;
}
// if current letter has zero width move the tape to previous positive width letter
if ($(letters[inputLength] as Element).outerWidth(true) === 0)
currentWordWidth -= lastPositiveLetterWidth;
}
/* change to new #words & .afterNewline margins */
const tapeMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
const newMargin = tapeMargin - (wordsWidthBeforeActive + currentWordWidth);
let newMargin =
wordsWrapperWidth * (Config.tapeMargin / 100) -
wordsWidthBeforeActive -
currentWordWidth;
if (isLanguageRTL) newMargin = wordRightMargin - newMargin;
const jqWords = $(wordsEl);
if (Config.smoothLineScroll) {