From c20964d185ecbe14ccbb6c7699b6365983c88d2e Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+NadAlaba@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:14:53 +0300 Subject: [PATCH] fix(indicate typos): letters not displaying correctly in RTL languages or with ligatures when set to below (NadAlaba) (#5113) --- frontend/src/styles/test.scss | 8 +- .../src/ts/controllers/input-controller.ts | 18 +- frontend/src/ts/test/caret.ts | 19 ++- frontend/src/ts/test/pace-caret.ts | 36 ++-- frontend/src/ts/test/test-ui.ts | 156 +++++++++++++++++- 5 files changed, 202 insertions(+), 35 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 9f82f226107a..862eba61da7f 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -347,6 +347,7 @@ } .word { + position: relative; font-size: 1em; line-height: 1em; margin: 0.25em; @@ -440,12 +441,11 @@ .word letter.incorrect { color: var(--error-color); - position: relative; } -.word letter.incorrect hint { +.word .hints hint { position: absolute; - bottom: -1em; + bottom: -1.1em; color: var(--text-color); line-height: initial; font-size: 0.75em; @@ -454,9 +454,9 @@ left: 0; opacity: 0.5; text-align: center; - width: 100%; display: grid; justify-content: center; + transform: translate(-50%); } .word letter.incorrect.extra { diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index 69a8dfadc22f..3d4f44d161db 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -145,7 +145,7 @@ function backspaceToPrevious(): void { TestUI.setCurrentWordElementIndex(TestUI.currentWordElementIndex - 1); TestUI.updateActiveElement(true); Funbox.toggleScript(TestWords.words.getCurrent()); - TestUI.updateWordElement(); + void TestUI.updateWordElement(); if (Config.mode === "zen") { TimerProgress.update(); @@ -210,7 +210,7 @@ function handleSpace(): void { TestInput.incrementAccuracy(isWordCorrect); if (isWordCorrect) { if (Config.indicateTypos !== "off" && Config.stopOnError === "letter") { - TestUI.updateWordElement(); + void TestUI.updateWordElement(); } PaceCaret.handleSpace(true, currentWord); TestInput.input.pushHistory(); @@ -260,7 +260,7 @@ function handleSpace(): void { if (Config.stopOnError === "word") { dontInsertSpace = false; Replay.addReplayEvent("incorrectLetter", "_"); - TestUI.updateWordElement(true); + void TestUI.updateWordElement(true); void Caret.updatePosition(); } return; @@ -561,7 +561,7 @@ function handleChar( !Config.language.startsWith("korean") ) { TestInput.input.current = resultingWord; - TestUI.updateWordElement(); + void TestUI.updateWordElement(); void Caret.updatePosition(); return; } @@ -642,7 +642,7 @@ function handleChar( !thisCharCorrect ) { if (Config.indicateTypos !== "off") { - TestUI.updateWordElement(undefined, TestInput.input.current + char); + void TestUI.updateWordElement(undefined, TestInput.input.current + char); } return; } @@ -708,7 +708,7 @@ function handleChar( const activeWordTopBeforeJump = document.querySelector( "#words .word.active" )?.offsetTop as number; - TestUI.updateWordElement(); + void TestUI.updateWordElement(); if (!Config.hideExtraLetters) { const newActiveTop = document.querySelector( @@ -729,7 +729,7 @@ function handleChar( if (!Config.showAllLines) TestUI.lineJump(currentTop); } else { TestInput.input.current = TestInput.input.current.slice(0, -1); - TestUI.updateWordElement(); + void TestUI.updateWordElement(); } } } @@ -1353,7 +1353,7 @@ $("#wordsInput").on("input", (event) => { TestInput.input.current = inputValue; } - TestUI.updateWordElement(); + void TestUI.updateWordElement(); void Caret.updatePosition(); if (!CompositionState.getComposing()) { const keyStroke = event?.originalEvent as InputEvent; @@ -1395,7 +1395,7 @@ $("#wordsInput").on("input", (event) => { const stateafter = CompositionState.getComposing(); if (statebefore !== stateafter) { - TestUI.updateWordElement(); + void TestUI.updateWordElement(); } // force caret at end of input diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index c85530a6af7d..6ed74bdb440c 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -72,10 +72,14 @@ export async function updatePosition(noAnim = false): Promise { const inputLen = TestInput.input.current.length; const currentLetterIndex = inputLen; + const activeWordEl = document?.querySelector("#words .active") as HTMLElement; //insert temporary character so the caret will work in zen mode - const activeWordEmpty = $("#words .active").children().length === 0; + const activeWordEmpty = activeWordEl?.children.length === 0; if (activeWordEmpty) { - $("#words .active").append('_'); + activeWordEl.insertAdjacentHTML( + "beforeend", + '_' + ); } const currentWordNodeList = document @@ -112,13 +116,16 @@ export async function updatePosition(noAnim = false): Promise { const diff = letterHeight - caret.offsetHeight; - let newTop = letterPosTop + diff / 2; + let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2; if (Config.caretStyle === "underline") { - newTop = letterPosTop - caret.offsetHeight / 2; + newTop = activeWordEl.offsetTop + letterPosTop - caret.offsetHeight / 2; } - let newLeft = letterPosLeft - (fullWidthCaret ? 0 : caretWidth / 2); + let newLeft = + activeWordEl.offsetLeft + + letterPosLeft - + (fullWidthCaret ? 0 : caretWidth / 2); const wordsWrapperWidth = $(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0; @@ -199,7 +206,7 @@ export async function updatePosition(noAnim = false): Promise { } } if (activeWordEmpty) { - $("#words .active").children().remove(); + activeWordEl?.replaceChildren(); } } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index e04e6a6a468d..bbba5d9ed33a 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -31,7 +31,7 @@ export function setLastTestWpm(wpm: number): void { } } -function resetCaretPosition(): void { +async function resetCaretPosition(): Promise { if (Config.paceCaret === "off" && !TestState.isPaceRepeat) return; if (!$("#paceCaret").hasClass("hidden")) { $("#paceCaret").addClass("hidden"); @@ -47,10 +47,15 @@ function resetCaretPosition(): void { if (firstLetter === undefined || firstLetterHeight === undefined) return; + const currentLanguage = await Misc.getCurrentLanguage(Config.language); + const isLanguageRightToLeft = currentLanguage.rightToLeft; + caret.stop(true, true).animate( { top: firstLetter.offsetTop - firstLetterHeight / 4, - left: firstLetter.offsetLeft, + left: + firstLetter.offsetLeft + + (isLanguageRightToLeft ? firstLetter.offsetWidth : 0), }, 0, "linear" @@ -121,10 +126,10 @@ export async function init(): Promise { wordsStatus: {}, timeout: null, }; - resetCaretPosition(); + await resetCaretPosition(); } -export function update(expectedStepEnd: number): void { +export async function update(expectedStepEnd: number): Promise { if (settings === null || !TestState.isActive || TestUI.resultVisible) { return; } @@ -210,15 +215,26 @@ export function update(expectedStepEnd: number): void { ); } + const currentLanguage = await Misc.getCurrentLanguage(Config.language); + const isLanguageRightToLeft = currentLanguage.rightToLeft; + newTop = + word.offsetTop + currentLetter.offsetTop - Config.fontSize * Misc.convertRemToPixels(1) * 0.1; newLeft; if (settings.currentLetterIndex === -1) { - newLeft = currentLetter.offsetLeft; + newLeft = + word.offsetLeft + + currentLetter.offsetLeft - + caretWidth / 2 + + (isLanguageRightToLeft ? currentLetterWidth : 0); } else { newLeft = - currentLetter.offsetLeft + currentLetterWidth - caretWidth / 2; + word.offsetLeft + + currentLetter.offsetLeft - + caretWidth / 2 + + (isLanguageRightToLeft ? 0 : currentLetterWidth); } caret.removeClass("hidden"); } catch (e) { @@ -254,11 +270,9 @@ export function update(expectedStepEnd: number): void { } } settings.timeout = setTimeout(() => { - try { - update(expectedStepEnd + (settings?.spc ?? 0) * 1000); - } catch (e) { + update(expectedStepEnd + (settings?.spc ?? 0) * 1000).catch(() => { settings = null; - } + }); }, duration); } catch (e) { console.error(e); @@ -296,7 +310,7 @@ export function handleSpace(correct: boolean, currentWord: string): void { } export function start(): void { - update(performance.now() + (settings?.spc ?? 0) * 1000); + void update(performance.now() + (settings?.spc ?? 0) * 1000); } ConfigEvent.subscribe((eventKey) => { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 2f1184654883..8496bd18f546 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -29,6 +29,81 @@ async function gethtml2canvas(): Promise { return (await import("html2canvas")).default; } +function createHintsHtml( + incorrectLtrIndices: number[][], + activeWordLetters: NodeListOf +): string { + let hintsHtml = ""; + for (const adjacentLetters of incorrectLtrIndices) { + for (const indx of adjacentLetters) { + const blockLeft = (activeWordLetters[indx] as HTMLElement).offsetLeft; + const blockWidth = (activeWordLetters[indx] as HTMLElement).offsetWidth; + const blockIndices = `[${indx}]`; + const blockChars = TestInput.input.current[indx]; + + hintsHtml += + `${blockChars}`; + } + } + hintsHtml = `
${hintsHtml}
`; + return hintsHtml; +} + +async function joinOverlappingHints( + incorrectLtrIndices: number[][], + activeWordLetters: NodeListOf, + hintElements: HTMLCollection +): Promise { + const currentLanguage = await Misc.getCurrentLanguage(Config.language); + const isLanguageRTL = currentLanguage.rightToLeft; + + let i = 0; + for (const adjacentLetters of incorrectLtrIndices) { + for (let j = 0; j < adjacentLetters.length - 1; j++) { + const block1El = hintElements[i] as HTMLElement; + const block2El = hintElements[i + 1] as HTMLElement; + const leftBlock = isLanguageRTL ? block2El : block1El; + const rightBlock = isLanguageRTL ? block1El : block2El; + + /** HintBlock.offsetLeft is at the center line of corresponding letters + * then "transform: translate(-50%)" aligns hints with letters */ + if ( + leftBlock.offsetLeft + leftBlock.offsetWidth / 2 > + rightBlock.offsetLeft - rightBlock.offsetWidth / 2 + ) { + block1El.dataset["length"] = ( + parseInt(block1El.dataset["length"] ?? "1") + + parseInt(block2El.dataset["length"] ?? "1") + ).toString(); + + const block1Indices = block1El.dataset["charsIndex"] ?? "[]"; + const block2Indices = block2El.dataset["charsIndex"] ?? "[]"; + block1El.dataset["charsIndex"] = + block1Indices.slice(0, -1) + "," + block2Indices.slice(1); + + const letter1Index = adjacentLetters[j] ?? 0; + const newLeft = + (activeWordLetters[letter1Index] as HTMLElement).offsetLeft + + (isLanguageRTL + ? (activeWordLetters[letter1Index] as HTMLElement).offsetWidth + : 0) + + (block2El.offsetLeft - block1El.offsetLeft); + block1El.style.left = newLeft.toString() + "px"; + + block1El.insertAdjacentHTML("beforeend", block2El.innerHTML); + + block2El.remove(); + adjacentLetters.splice(j + 1, 1); + i -= j === 0 ? 1 : 2; + j -= j === 0 ? 1 : 2; + } + i++; + } + i++; + } +} + const debouncedZipfCheck = debounce(250, async () => { const supports = await Misc.checkIfLanguageSupportsZipf(Config.language); if (supports === "no") { @@ -67,6 +142,10 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => { updateWordsHeight(true); updateWordsInputPosition(true); } + if (eventKey === "fontSize" || eventKey === "fontFamily") + updateHintsPosition().catch((e) => { + console.error(e); + }); if (eventKey === "theme") void applyBurstHeatmap(); @@ -79,7 +158,7 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => { if (typeof eventValue !== "boolean") return; if (eventKey === "flipTestColors") flipColors(eventValue); if (eventKey === "colorfulMode") colorful(eventValue); - if (eventKey === "highlightMode") updateWordElement(eventValue); + if (eventKey === "highlightMode") void updateWordElement(eventValue); if (eventKey === "burstHeatmap") void applyBurstHeatmap(); }); @@ -168,6 +247,53 @@ export function updateActiveElement( } } +async function updateHintsPosition(): Promise { + if ( + ActivePage.get() !== "test" || + resultVisible || + Config.indicateTypos !== "below" + ) + return; + + const currentLanguage = await Misc.getCurrentLanguage(Config.language); + const isLanguageRTL = currentLanguage.rightToLeft; + + let wordEl: HTMLElement | undefined; + let letterElements: NodeListOf | undefined; + + const hintElements = document + .getElementById("words") + ?.querySelectorAll("div.word > div.hints > hint"); + for (let i = 0; i < (hintElements?.length ?? 0); i++) { + const hintEl = hintElements?.[i] as HTMLElement; + + if (!wordEl || hintEl.parentElement?.parentElement !== wordEl) { + wordEl = hintEl.parentElement?.parentElement as HTMLElement; + letterElements = wordEl?.querySelectorAll("letter"); + } + + const letterIndices = hintEl.dataset["charsIndex"] + ?.slice(1, -1) + .split(",") + .map((indx) => parseInt(indx)); + const leftmostIndx = isLanguageRTL + ? parseInt(hintEl.dataset["length"] ?? "1") - 1 + : 0; + let newLeft = ( + letterElements?.[letterIndices?.[leftmostIndx] ?? 0] as HTMLElement + ).offsetLeft; + const lettersWidth = + letterIndices?.reduce( + (accum, curr) => + accum + (letterElements?.[curr] as HTMLElement).offsetWidth, + 0 + ) ?? 0; + newLeft += lettersWidth / 2; + + hintEl.style.left = newLeft.toString() + "px"; + } +} + function getWordHTML(word: string): string { let newlineafter = false; let retval = `
`; @@ -570,15 +696,18 @@ export async function screenshot(): Promise { }, 3000); } -export function updateWordElement( +export async function updateWordElement( showError = !Config.blindMode, inputOverride?: string -): void { +): Promise { const input = inputOverride ?? TestInput.input.current; - const wordAtIndex = document.querySelector("#words .word.active") as Element; + const wordAtIndex = document.querySelector( + "#words .word.active" + ) as HTMLElement; const currentWord = TestWords.words.getCurrent(); if (!currentWord && Config.mode !== "zen") return; let ret = ""; + const hintIndices: number[][] = []; let newlineafter = false; @@ -711,8 +840,15 @@ export function updateWordElement( ? "_" : input[i] : currentLetter) + - (Config.indicateTypos === "below" ? `${input[i]}` : "") + ""; + if (Config.indicateTypos === "below") { + if (!hintIndices?.length) hintIndices.push([i]); + else { + const lastblock = hintIndices[hintIndices.length - 1]; + if (lastblock?.[lastblock.length - 1] === i - 1) lastblock.push(i); + else hintIndices.push([i]); + } + } } } @@ -741,7 +877,17 @@ export function updateWordElement( } } } + wordAtIndex.innerHTML = ret; + + if (hintIndices?.length) { + const activeWordLetters = wordAtIndex.querySelectorAll("letter"); + const hintsHtml = createHintsHtml(hintIndices, activeWordLetters); + wordAtIndex.insertAdjacentHTML("beforeend", hintsHtml); + const hintElements = wordAtIndex.getElementsByTagName("hint"); + await joinOverlappingHints(hintIndices, activeWordLetters, hintElements); + } + if (newlineafter) $("#words").append("
"); }