From 5ca47e116b9b3fa2fa5659e325151b328f42c4f0 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+NadAlaba@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:02:43 +0300 Subject: [PATCH] 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` ``s in `.withLigatures` lanuages. These languages make the display of `` elements `inline` in order to allow the joining of letters. However, this causes ``'s `border-bottom` to be ignored, which changes `.word` height, so we add a `padding-bottom` to the `.word` in that case. - Also, `inline` ``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 --- frontend/src/styles/test.scss | 31 ++++++++-- frontend/src/ts/test/caret.ts | 16 ++++- frontend/src/ts/test/test-logic.ts | 7 --- frontend/src/ts/test/test-ui.ts | 94 +++++++++++++++++++++--------- 4 files changed, 106 insertions(+), 42 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 38da32ebe..18639295a 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -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; + } } } } diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index bd9f5c89e..b221537ed 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -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; } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e128c3b4f..3c67ff694 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -455,13 +455,6 @@ export async function init(): Promise { } } - 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; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index be35a26b4..53711f22b 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -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(".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 { +export async function scrollTape(noRemove = false): Promise { 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 { 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 { 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 { } /* 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 { 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) {