mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-06 05:26:54 +08:00
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:
parent
85543ffa19
commit
5ca47e116b
4 changed files with 106 additions and 42 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Reference in a new issue