From f6501301a135e48a8d29f06f948aaf7033b8a074 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:43:11 +0300 Subject: [PATCH 1/4] support mixed language directions in tape mode also center current letter, so that it stays in the same position in RTL and LTR tape --- frontend/src/ts/elements/caret.ts | 46 +++++++-------- frontend/src/ts/test/test-ui.ts | 93 +++++++++++++++++++++---------- 2 files changed, 86 insertions(+), 53 deletions(-) diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 417c5ab8bd63..098bbca90f76 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -446,59 +446,53 @@ export class Caret { } const spaceWidth = getTotalInlineMargin(options.word.native); - let width = spaceWidth; - if (this.isFullWidth() && options.side === "beforeLetter") { - width = letter.getOffsetWidth(); + let width = letter.getOffsetWidth(); + if (options.side === "afterLetter") { + width = spaceWidth; } let left = 0; let top = 0; - const tapeOffset = - wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100); + const isTestRightToLeft = options.isDirectionReversed + ? !options.isLanguageRightToLeft + : options.isLanguageRightToLeft; + + let tapeOffsetRatio = Config.tapeMargin / 100; + if (isTestRightToLeft) tapeOffsetRatio = 1 - tapeOffsetRatio; + const tapeOffset = wordsWrapperCache.getOffsetWidth() * tapeOffsetRatio; // yes, this is all super verbose, but its easier to maintain and understand if (isWordRTL) { if (!checkRtlByLetter && isFullMatch) options.word.addClass("wordRtl"); let afterLetterCorrection = 0; - if (options.side === "afterLetter") { - if (this.isFullWidth()) { - afterLetterCorrection += spaceWidth * -1; - } else { - afterLetterCorrection += letter.getOffsetWidth() * -1; - } + if (this.isFullWidth() && options.side === "afterLetter") { + afterLetterCorrection += spaceWidth * -1; + } else if (!this.isFullWidth() && options.side === "beforeLetter") { + afterLetterCorrection += width; } if (Config.tapeMode === "off") { - if (!this.isFullWidth()) { - left += letter.getOffsetWidth(); - } left += letter.getOffsetLeft(); left += options.word.getOffsetLeft(); left += afterLetterCorrection; } else if (Config.tapeMode === "word") { - if (!this.isFullWidth()) { - left += letter.getOffsetWidth(); - } - left += options.word.getOffsetWidth() * -1; left += letter.getOffsetLeft(); left += afterLetterCorrection; if (this.isMainCaret && lockedMainCaretInTape) { - left += wordsWrapperCache.getOffsetWidth() - tapeOffset; + left += tapeOffset - options.word.getOffsetWidth(); + left += spaceWidth * 0.5; // center current letter } else { left += options.word.getOffsetLeft(); - left += options.word.getOffsetWidth(); } } else if (Config.tapeMode === "letter") { - if (this.isFullWidth()) { - left += width * -1; - } if (this.isMainCaret && lockedMainCaretInTape) { - left += wordsWrapperCache.getOffsetWidth() - tapeOffset; + left += tapeOffset; + if (this.isFullWidth()) left += width * -1; + left += spaceWidth * 0.5; // center current letter } else { left += letter.getOffsetLeft(); left += options.word.getOffsetLeft(); left += afterLetterCorrection; - left += width; } } } else { @@ -515,12 +509,14 @@ export class Caret { left += afterLetterCorrection; if (this.isMainCaret && lockedMainCaretInTape) { left += tapeOffset; + left += spaceWidth * -0.5; // center current letter } else { left += options.word.getOffsetLeft(); } } else if (Config.tapeMode === "letter") { if (this.isMainCaret && lockedMainCaretInTape) { left += tapeOffset; + left += spaceWidth * -0.5; // center current letter } else { left += letter.getOffsetLeft(); left += options.word.getOffsetLeft(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 71e3ca0fa80c..bbeb976bee5d 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -69,6 +69,8 @@ import { import { getTheme } from "../states/theme"; import { skipBreakdownEvent } from "../states/header"; import { wordsHaveNewline } from "../states/test"; +import { isWordRightToLeft } from "../utils/strings"; +import { getTotalInlineMargin } from "../utils/misc"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, @@ -995,9 +997,7 @@ export async function scrollTape(noAnimation = false): Promise { } } - const wordRightMargin = parseFloat( - window.getComputedStyle(activeWordEl.native).marginRight, - ); + const spaceWidth = getTotalInlineMargin(activeWordEl.native); /*calculate .afterNewline & #words new margins + determine elements to remove*/ for (let i = 0; i <= lastElementIndex; i++) { @@ -1024,7 +1024,7 @@ export async function scrollTape(noAnimation = false): Promise { } else if (child.hasClass("afterNewline")) { if (leadingNewLine) continue; const nlCharWidth = getNlCharWidth(wordsChildrenArr[i - 3]); - fullLineWidths -= nlCharWidth + wordRightMargin; + fullLineWidths -= nlCharWidth + spaceWidth; if (i < activeWordIndex) wordsWidthBeforeActive = fullLineWidths; /** words that are wider than limit can cause a barely visible bottom line shifting, @@ -1067,43 +1067,80 @@ export async function scrollTape(noAnimation = false): Promise { } /* calculate current word width to add to #words margin */ + let inputWord = TestInput.input.current; + const targetWord = TestWords.words.getCurrent() || inputWord; // fallback for zen mode + const [isActiveWordRTL, _] = isWordRightToLeft( + targetWord, + TestState.isLanguageRightToLeft, + TestState.isDirectionReversed, + ); let currentWordWidth = 0; - const inputLength = TestInput.input.current.length; - if (Config.tapeMode === "letter" && inputLength > 0) { - const letters = activeWordEl.qsa("letter"); - let lastPositiveLetterWidth = 0; - for (let i = 0; i < inputLength; i++) { - const letter = letters[i]; - if ( - (Config.blindMode || Config.hideExtraLetters) && - letter?.hasClass("extra") - ) { - continue; - } - const letterOuterWidth = letter?.getOffsetWidth() ?? 0; - currentWordWidth += letterOuterWidth; - if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth; + if (Config.tapeMode === "letter") { + let inputLength = inputWord.length; + const targetWordLength = targetWord.length; + if (Config.blindMode || Config.hideExtraLetters) { + inputLength = Math.min(targetWordLength, inputLength); + } + + let letterIndex; + let side: "beforeLetter" | "afterLetter"; + if ( + inputLength < targetWordLength || + (Config.mode === "zen" && inputLength === 0) + ) { + side = "beforeLetter"; + letterIndex = inputLength; + } else { + side = "afterLetter"; + letterIndex = inputLength - 1; } + // if current letter has zero width move the tape to previous positive width letter - if (letters[inputLength]?.getOffsetWidth() === 0) { - currentWordWidth -= lastPositiveLetterWidth; + let i = letterIndex; + const letters = activeWordEl.qsa("letter"); + let ltr; + while ((ltr = letters[i]) && ltr.getOffsetWidth() === 0 && i > 0) i--; + let currentLetterOffset = ltr?.getOffsetLeft() ?? 0; + let currentLetterWidth = ltr?.getOffsetWidth() ?? 0; + if (side === "afterLetter") currentLetterWidth = spaceWidth; + + if ( + (isActiveWordRTL && side === "beforeLetter") || + (!isActiveWordRTL && side === "afterLetter") + ) { + currentLetterOffset += currentLetterWidth; + } + + if (isTestRightToLeft) { + currentWordWidth = activeWordEl.getOffsetWidth() - currentLetterOffset; + } else { + currentWordWidth = currentLetterOffset; } } + if (Config.tapeMode === "word" && isTestRightToLeft !== isActiveWordRTL) { + currentWordWidth += activeWordEl.getOffsetWidth(); + } + /* change to new #words & .afterNewline margins */ - const tapeMarginPx = wordsWrapperWidth * (Config.tapeMargin / 100); - let newMarginOffset = wordsWidthBeforeActive + currentWordWidth; - let newMargin = tapeMarginPx - newMarginOffset; + let tapeMarginPx = wordsWrapperWidth * (Config.tapeMargin / 100); + let typedWidth = -1 * (wordsWidthBeforeActive + currentWordWidth); + let newMargin = tapeMarginPx + typedWidth; if (isTestRightToLeft) { - newMarginOffset *= -1; - newMargin = wordRightMargin - newMargin; + typedWidth *= -1; + newMargin *= -1; + newMargin += spaceWidth; } + // center current letter + if (isActiveWordRTL) newMargin += 0.5 * spaceWidth; + else newMargin -= 0.5 * spaceWidth; + const duration = noAnimation ? 0 : 125; const ease = "inOut(1.25)"; const caretScrollOptions = { - newValue: newMarginOffset * -1, + newValue: typedWidth, duration: Config.smoothLineScroll ? duration : 0, ease, }; @@ -2013,7 +2050,7 @@ qs("#wordsInput")?.on("focusout", () => { if (!isInputElementFocused()) { OutOfFocus.show(); } - Caret.hide(); + //Caret.hide(); }); qs(".pageTest")?.onChild("click", "#showWordHistoryButton", () => { From 55e3dd9f1597e6ea996dff5464861c0d94435213 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:16:20 +0300 Subject: [PATCH 2/4] fix tape sometimes calculates before funbox css is loaded on quick restart --- frontend/src/ts/test/funbox/funbox.ts | 39 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/funbox/funbox.ts b/frontend/src/ts/test/funbox/funbox.ts index de19d9a93452..1625cc18fb8c 100644 --- a/frontend/src/ts/test/funbox/funbox.ts +++ b/frontend/src/ts/test/funbox/funbox.ts @@ -219,16 +219,41 @@ async function setFunboxBodyClasses(): Promise { return true; } -async function applyFunboxCSS(): Promise { +async function applyFunboxCSS(): Promise { qsa(".funBoxTheme").remove(); + + await Promise.all( + getActiveFunboxesWithProperty("hasCssFile").map( + async (funbox) => + new Promise((resolve, reject) => { + const css = document.createElement("link"); + css.classList.add("funBoxTheme"); + css.rel = "stylesheet"; + css.href = "funbox/" + funbox.name + ".css"; + css.onload = () => resolve(); + css.onerror = reject; + document.head.appendChild(css); + }), + ), + ); + + /* + const promises = []; for (const funbox of getActiveFunboxesWithProperty("hasCssFile")) { - const css = document.createElement("link"); - css.classList.add("funBoxTheme"); - css.rel = "stylesheet"; - css.href = "funbox/" + funbox.name + ".css"; - document.head.appendChild(css); + promises.push( + new Promise((resolve, reject) => { + const css = document.createElement("link"); + css.classList.add("funBoxTheme"); + css.rel = "stylesheet"; + css.href = "funbox/" + funbox.name + ".css"; + css.onload = () => resolve(); + css.onerror = reject; + document.head.appendChild(css); + }), + ); } - return true; + await Promise.all(promises); + */ } configEvent.subscribe(async ({ key }) => { From f112e17bc24f529fd71cbd49878b1a36c49e5930 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:08:30 +0300 Subject: [PATCH 3/4] remove debug --- frontend/src/ts/test/test-ui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index bbeb976bee5d..94443a0e077a 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -2050,7 +2050,7 @@ qs("#wordsInput")?.on("focusout", () => { if (!isInputElementFocused()) { OutOfFocus.show(); } - //Caret.hide(); + Caret.hide(); }); qs(".pageTest")?.onChild("click", "#showWordHistoryButton", () => { From c32f35f7609c0d00ba79e2e40c09d0b5f02299a3 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:09:46 +0300 Subject: [PATCH 4/4] const --- frontend/src/ts/test/test-ui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 94443a0e077a..70277e586022 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1123,7 +1123,7 @@ export async function scrollTape(noAnimation = false): Promise { } /* change to new #words & .afterNewline margins */ - let tapeMarginPx = wordsWrapperWidth * (Config.tapeMargin / 100); + const tapeMarginPx = wordsWrapperWidth * (Config.tapeMargin / 100); let typedWidth = -1 * (wordsWidthBeforeActive + currentWordWidth); let newMargin = tapeMarginPx + typedWidth; if (isTestRightToLeft) {