Skip to content

Commit 5ca47e1

Browse files
nadalabaMiodec
andauthored
impr(tape mode): support RTL languages (@nadalaba) (monkeytypegame#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 monkeytypegame#3923 --------- Co-authored-by: Jack <[email protected]>
1 parent 85543ff commit 5ca47e1

File tree

4 files changed

+106
-42
lines changed

4 files changed

+106
-42
lines changed

frontend/src/styles/test.scss

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,6 @@
296296
&.tape {
297297
display: block;
298298
white-space: nowrap;
299-
width: 200vw;
300299
.word {
301300
margin: 0.25em 0.6em 0.25em 0;
302301
display: inline-block;
@@ -314,8 +313,21 @@
314313
}
315314
}
316315
&.withLigatures {
317-
letter {
318-
display: inline;
316+
.word {
317+
overflow-wrap: anywhere;
318+
padding-bottom: 0.05em; // compensate for letter border
319+
320+
.hints {
321+
overflow-wrap: initial;
322+
}
323+
324+
letter {
325+
display: inline;
326+
}
327+
}
328+
.beforeNewline {
329+
border-top: unset;
330+
padding-bottom: 0.05em;
319331
}
320332
}
321333
&.blurred {
@@ -743,8 +755,17 @@
743755
}
744756
}
745757
&.withLigatures {
746-
letter {
747-
display: inline;
758+
.word {
759+
overflow-wrap: anywhere;
760+
padding-bottom: 2px; // compensate for letter border
761+
762+
.hints {
763+
overflow-wrap: initial;
764+
}
765+
766+
letter {
767+
display: inline;
768+
}
748769
}
749770
}
750771
}

frontend/src/ts/test/caret.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,29 @@ function getTargetPositionLeft(
107107
} else {
108108
const wordsWrapperWidth =
109109
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
110-
const tapeMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
110+
const tapeMargin =
111+
wordsWrapperWidth *
112+
(isLanguageRightToLeft
113+
? 1 - Config.tapeMargin / 100
114+
: Config.tapeMargin / 100);
111115

112116
result =
113117
tapeMargin -
114118
(fullWidthCaret && isLanguageRightToLeft ? fullWidthCaretWidth : 0);
115119

116120
if (Config.tapeMode === "word" && inputLen > 0) {
117121
let currentWordWidth = 0;
122+
let lastPositiveLetterWidth = 0;
118123
for (let i = 0; i < inputLen; i++) {
119124
if (invisibleExtraLetters && i >= wordLen) break;
120-
currentWordWidth +=
121-
$(currentWordNodeList[i] as HTMLElement).outerWidth(true) ?? 0;
125+
const letterOuterWidth =
126+
$(currentWordNodeList[i] as Element).outerWidth(true) ?? 0;
127+
currentWordWidth += letterOuterWidth;
128+
if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth;
122129
}
130+
// if current letter has zero width move the caret to previous positive width letter
131+
if ($(currentWordNodeList[inputLen] as Element).outerWidth(true) === 0)
132+
currentWordWidth -= lastPositiveLetterWidth;
123133
if (isLanguageRightToLeft) currentWordWidth *= -1;
124134
result += currentWordWidth;
125135
}

frontend/src/ts/test/test-logic.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,6 @@ export async function init(): Promise<void | null> {
455455
}
456456
}
457457

458-
if (Config.tapeMode !== "off" && language.rightToLeft) {
459-
Notifications.add("This language does not support tape mode.", 0, {
460-
important: true,
461-
});
462-
UpdateConfig.setTapeMode("off");
463-
}
464-
465458
const allowLazyMode = !language.noLazyMode || Config.mode === "custom";
466459
if (Config.lazyMode && !allowLazyMode) {
467460
rememberLazyMode = true;

frontend/src/ts/test/test-ui.ts

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,10 @@ export function showWords(): void {
413413
}
414414

415415
updateActiveElement(undefined, true);
416+
updateWordWrapperClasses();
416417
setTimeout(() => {
417418
void Caret.updatePosition();
418419
}, 125);
419-
420-
updateWordWrapperClasses();
421420
}
422421

423422
export function appendEmptyWordElement(): void {
@@ -598,12 +597,32 @@ export function updateWordsWrapperHeight(force = false): void {
598597

599598
function updateWordsMargin(): void {
600599
if (Config.tapeMode !== "off") {
601-
void scrollTape();
600+
void scrollTape(true);
602601
} else {
603-
setTimeout(() => {
604-
$("#words").css("margin-left", "unset");
605-
$("#words .afterNewline").css("margin-left", "unset");
606-
}, 0);
602+
const wordsEl = document.getElementById("words") as HTMLElement;
603+
const afterNewlineEls =
604+
wordsEl.querySelectorAll<HTMLElement>(".afterNewline");
605+
if (Config.smoothLineScroll) {
606+
const jqWords = $(wordsEl);
607+
jqWords.stop("leftMargin", true, false).animate(
608+
{
609+
marginLeft: 0,
610+
},
611+
{
612+
duration: SlowTimer.get() ? 0 : 125,
613+
queue: "leftMargin",
614+
}
615+
);
616+
jqWords.dequeue("leftMargin");
617+
$(afterNewlineEls)
618+
.stop(true, false)
619+
.animate({ marginLeft: 0 }, SlowTimer.get() ? 0 : 125);
620+
} else {
621+
wordsEl.style.marginLeft = `0`;
622+
for (const afterNewline of afterNewlineEls) {
623+
afterNewline.style.marginLeft = `0`;
624+
}
625+
}
607626
}
608627
}
609628

@@ -816,11 +835,14 @@ function getNlCharWidth(
816835
return nlChar.offsetWidth + letterMargin;
817836
}
818837

819-
export async function scrollTape(): Promise<void> {
838+
export async function scrollTape(noRemove = false): Promise<void> {
820839
if (ActivePage.get() !== "test" || resultVisible) return;
821840

822841
await centeringActiveLine;
823842

843+
const currentLang = await JSONData.getCurrentLanguage(Config.language);
844+
const isLanguageRTL = currentLang.rightToLeft;
845+
824846
// index of the active word in the collection of .word elements
825847
const wordElementIndex = TestState.activeWordIndex - activeWordElementOffset;
826848
const wordsWrapperWidth = (
@@ -898,7 +920,10 @@ export async function scrollTape(): Promise<void> {
898920
const wordOuterWidth = $(child).outerWidth(true) ?? 0;
899921
const forWordLeft = Math.floor(child.offsetLeft);
900922
const forWordWidth = Math.floor(child.offsetWidth);
901-
if (forWordLeft < 0 - forWordWidth) {
923+
if (
924+
(!isLanguageRTL && forWordLeft < 0 - forWordWidth) ||
925+
(isLanguageRTL && forWordLeft > wordsWrapperWidth)
926+
) {
902927
toRemove.push(child);
903928
widthRemoved += wordOuterWidth;
904929
wordsToRemoveCount++;
@@ -912,15 +937,20 @@ export async function scrollTape(): Promise<void> {
912937
fullLineWidths -= nlCharWidth + wordRightMargin;
913938
if (i < activeWordIndex) wordsWidthBeforeActive = fullLineWidths;
914939

915-
if (fullLineWidths < wordsEl.offsetWidth) {
940+
/** words that are wider than limit can cause a barely visible bottom line shifting,
941+
* increase limit if that ever happens, but keep the limit because browsers hate
942+
* ridiculously wide margins which may cause the words to not be displayed
943+
*/
944+
const limit = 3 * wordsEl.offsetWidth;
945+
if (fullLineWidths < limit) {
916946
afterNewlinesNewMargins.push(fullLineWidths);
917947
widthRemovedFromLine.push(widthRemoved);
918948
} else {
919-
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
949+
afterNewlinesNewMargins.push(limit);
920950
widthRemovedFromLine.push(widthRemoved);
921951
if (i < lastElementIndex) {
922952
// for the second .afterNewline after active word
923-
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
953+
afterNewlinesNewMargins.push(limit);
924954
widthRemovedFromLine.push(widthRemoved);
925955
}
926956
break;
@@ -929,7 +959,7 @@ export async function scrollTape(): Promise<void> {
929959
}
930960

931961
/* remove overflown elements */
932-
if (toRemove.length > 0) {
962+
if (toRemove.length > 0 && !noRemove) {
933963
activeWordElementOffset += wordsToRemoveCount;
934964
for (const el of toRemove) el.remove();
935965
for (let i = 0; i < widthRemovedFromLine.length; i++) {
@@ -940,30 +970,40 @@ export async function scrollTape(): Promise<void> {
940970
currentLineIndent - (widthRemovedFromLine[i] ?? 0)
941971
}px`;
942972
}
973+
if (isLanguageRTL) widthRemoved *= -1;
943974
const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0;
944975
wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`;
945976
}
946977

947978
/* calculate current word width to add to #words margin */
948979
let currentWordWidth = 0;
949-
if (Config.tapeMode === "letter") {
950-
if (TestInput.input.current.length > 0) {
951-
const letters = activeWordEl.querySelectorAll("letter");
952-
for (let i = 0; i < TestInput.input.current.length; i++) {
953-
const letter = letters[i] as HTMLElement;
954-
if (
955-
(Config.blindMode || Config.hideExtraLetters) &&
956-
letter.classList.contains("extra")
957-
) {
958-
continue;
959-
}
960-
currentWordWidth += $(letter).outerWidth(true) ?? 0;
980+
const inputLength = TestInput.input.current.length;
981+
if (Config.tapeMode === "letter" && inputLength > 0) {
982+
const letters = activeWordEl.querySelectorAll("letter");
983+
let lastPositiveLetterWidth = 0;
984+
for (let i = 0; i < inputLength; i++) {
985+
const letter = letters[i] as HTMLElement;
986+
if (
987+
(Config.blindMode || Config.hideExtraLetters) &&
988+
letter.classList.contains("extra")
989+
) {
990+
continue;
961991
}
992+
const letterOuterWidth = $(letter).outerWidth(true) ?? 0;
993+
currentWordWidth += letterOuterWidth;
994+
if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth;
962995
}
996+
// if current letter has zero width move the tape to previous positive width letter
997+
if ($(letters[inputLength] as Element).outerWidth(true) === 0)
998+
currentWordWidth -= lastPositiveLetterWidth;
963999
}
1000+
9641001
/* change to new #words & .afterNewline margins */
965-
const tapeMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
966-
const newMargin = tapeMargin - (wordsWidthBeforeActive + currentWordWidth);
1002+
let newMargin =
1003+
wordsWrapperWidth * (Config.tapeMargin / 100) -
1004+
wordsWidthBeforeActive -
1005+
currentWordWidth;
1006+
if (isLanguageRTL) newMargin = wordRightMargin - newMargin;
9671007

9681008
const jqWords = $(wordsEl);
9691009
if (Config.smoothLineScroll) {

0 commit comments

Comments
 (0)