mirror of
				https://github.com/monkeytypegame/monkeytype.git
				synced 2025-11-01 03:39:15 +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