From 5026f419540f49de48b30767a26c31e7aed08096 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 19 Sep 2025 18:59:46 +0200 Subject: [PATCH 1/3] impr: add custom error codes to contracts and api doc (@fehmer) (#6976) --- packages/contracts/src/results.ts | 8 ++++++++ packages/contracts/src/util/api.ts | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts index e047c0aadd05..642f05b1784a 100644 --- a/packages/contracts/src/results.ts +++ b/packages/contracts/src/results.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { CommonResponses, meta, + MonkeyClientError, MonkeyResponseSchema, responseWithData, } from "./util/api"; @@ -131,6 +132,13 @@ export const resultsContract = c.router( body: AddResultRequestSchema.strict(), responses: { 200: AddResultResponseSchema, + 460: MonkeyClientError.describe("Test too short"), + 461: MonkeyClientError.describe("Result hash invalid"), + 462: MonkeyClientError.describe("Result spacing invalid"), + 463: MonkeyClientError.describe("Result data invalid"), + 464: MonkeyClientError.describe("Missing key data"), + 465: MonkeyClientError.describe("Bot detected"), + 466: MonkeyClientError.describe("Duplicate result"), }, metadata: meta({ rateLimit: "resultsAdd", diff --git a/packages/contracts/src/util/api.ts b/packages/contracts/src/util/api.ts index 7697b3e54a66..e9c1de371365 100644 --- a/packages/contracts/src/util/api.ts +++ b/packages/contracts/src/util/api.ts @@ -120,6 +120,10 @@ export const CommonResponses = { 403: MonkeyClientError.describe("Operation not permitted"), 422: MonkeyValidationErrorSchema.describe("Request validation failed"), 429: MonkeyClientError.describe("Rate limit exceeded"), + 470: MonkeyClientError.describe("Invalid ApeKey"), + 471: MonkeyClientError.describe("ApeKey is inactive"), + 472: MonkeyClientError.describe("ApeKey is malformed"), + 479: MonkeyClientError.describe("ApeKey rate limit exceeded"), 500: MonkeyServerError.describe("Generic server error"), 503: MonkeyServerError.describe( "Endpoint disabled or server is under maintenance" From 43186017999268697d65363c6e7a8abf3f42d1f6 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+NadAlaba@users.noreply.github.com> Date: Fri, 19 Sep 2025 23:15:03 +0300 Subject: [PATCH 2/3] fix(funbox): caret/tape/ui issues in backwards funbox (@NadAlaba) (#6956) ### Description 1. refactor: - store (language direction / state of direction reversing funbox) in test-state.ts, and set them on `TestLogic.init()` which is called on each restart which happens on each change of Config.language or funbox. - use these new direction variables in (caret.ts / test-ui.ts / pace-caret.ts /result-word-highlight.ts) instead of calling `await JSONData.getCurrentLanguage(Config.language)`. 2. css changes: - add `unicode-bidi: bidi-override;` to .words with ligatures in backwards to fix the direction of LTR words on LTR languages in custom tests (which now have `.withLigatures` class regardless of language). - remove `direction: rtl;` from right to left .word and keep it on right to left #words. This was done because after adding the above `bidi-override`, (.word)s directioin was being forced to rtl on tests with RTL language and RTL words (custom and none custom tests), which is wrong (should be ltr on those tests because of the backwards funbox). - P.S., removing this from .word does not affect normal tests, because .word direction is inherited from #words directtion on non .withLigatures tests (e.g, non custom tests in non withLigatures languages), and it is calculated using internal browser algorithm based on characters used in .withLigatures tests (tests in languages with ligatures and all custom tests). 3. add the property "reverseDirection" to backwards funbox, which signifies that the direction of the test should be the reverse of the direction of Config.language, and the direction of a word should be the reverse of `Strings.isWordRightToLeft()`. 4. allow backwards funbox to work on languages with ligatures. 5. move `void Caret.updatePosition()` call to after the call of `TestUI.lineJump()` in `input-controller.ts:handleSpace()`. 6. change name of `Strings.getWordDirection()` to `Strings.isWordRightToLeft()` which explains what does the returned boolean mean, and add a parameter `reverseDirection` that flips the final result if true. --------- Co-authored-by: Miodec --- frontend/__tests__/utils/strings.spec.ts | 46 ++++++++++++------- frontend/src/styles/test.scss | 8 ---- .../src/ts/controllers/input-controller.ts | 7 +-- .../src/ts/elements/result-word-highlight.ts | 12 ++--- frontend/src/ts/test/caret.ts | 24 ++++------ frontend/src/ts/test/pace-caret.ts | 30 +++++------- frontend/src/ts/test/test-logic.ts | 10 +++- frontend/src/ts/test/test-state.ts | 10 ++++ frontend/src/ts/test/test-ui.ts | 39 +++++++++------- frontend/src/ts/ui.ts | 2 +- frontend/src/ts/utils/strings.ts | 15 +++--- frontend/static/funbox/backwards.css | 6 ++- packages/funbox/src/list.ts | 2 +- packages/funbox/src/types.ts | 1 + 14 files changed, 114 insertions(+), 98 deletions(-) diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 062eefd46e0b..78ee18de2b8c 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -270,7 +270,7 @@ describe("string utils", () => { ); }); - describe("getWordDirection", () => { + describe("isWordRightToLeft", () => { beforeEach(() => { Strings.clearWordDirectionCache(); }); @@ -321,13 +321,27 @@ describe("string utils", () => { languageRTL: boolean, _description: string ) => { - expect(Strings.getWordDirection(word, languageRTL)).toBe(expected); + expect(Strings.isWordRightToLeft(word, languageRTL)).toBe(expected); } ); it("should return languageRTL for undefined word", () => { - expect(Strings.getWordDirection(undefined, false)).toBe(false); - expect(Strings.getWordDirection(undefined, true)).toBe(true); + expect(Strings.isWordRightToLeft(undefined, false)).toBe(false); + expect(Strings.isWordRightToLeft(undefined, true)).toBe(true); + }); + + // testing reverseDirection + it("should return true for LTR word with reversed direction", () => { + expect(Strings.isWordRightToLeft("hello", false, true)).toBe(true); + expect(Strings.isWordRightToLeft("hello", true, true)).toBe(true); + }); + it("should return false for RTL word with reversed direction", () => { + expect(Strings.isWordRightToLeft("مرحبا", true, true)).toBe(false); + expect(Strings.isWordRightToLeft("مرحبا", false, true)).toBe(false); + }); + it("should return reverse of languageRTL for undefined word with reversed direction", () => { + expect(Strings.isWordRightToLeft(undefined, false, true)).toBe(true); + expect(Strings.isWordRightToLeft(undefined, true, true)).toBe(false); }); describe("caching", () => { @@ -349,7 +363,7 @@ describe("string utils", () => { it("should use cache for repeated calls", () => { // First call should cache the result (cache miss) - const result1 = Strings.getWordDirection("hello", false); + const result1 = Strings.isWordRightToLeft("hello", false); expect(result1).toBe(false); expect(mapSetSpy).toHaveBeenCalledWith("hello", false); @@ -358,7 +372,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); // Second call should use cache (cache hit) - const result2 = Strings.getWordDirection("hello", false); + const result2 = Strings.isWordRightToLeft("hello", false); expect(result2).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again @@ -367,7 +381,7 @@ describe("string utils", () => { mapGetSpy.mockClear(); mapSetSpy.mockClear(); - const result3 = Strings.getWordDirection("hello", true); + const result3 = Strings.isWordRightToLeft("hello", true); expect(result3).toBe(false); // Still false because "hello" is LTR regardless of language expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again @@ -375,7 +389,7 @@ describe("string utils", () => { it("should cache based on core word without punctuation", () => { // First call should cache the result for core "hello" - const result1 = Strings.getWordDirection("hello", false); + const result1 = Strings.isWordRightToLeft("hello", false); expect(result1).toBe(false); expect(mapSetSpy).toHaveBeenCalledWith("hello", false); @@ -383,7 +397,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); // These should all use the same cache entry since they have the same core - const result2 = Strings.getWordDirection("hello!", false); + const result2 = Strings.isWordRightToLeft("hello!", false); expect(result2).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); @@ -391,7 +405,7 @@ describe("string utils", () => { mapGetSpy.mockClear(); mapSetSpy.mockClear(); - const result3 = Strings.getWordDirection("!hello", false); + const result3 = Strings.isWordRightToLeft("!hello", false); expect(result3).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); @@ -399,7 +413,7 @@ describe("string utils", () => { mapGetSpy.mockClear(); mapSetSpy.mockClear(); - const result4 = Strings.getWordDirection("!hello!", false); + const result4 = Strings.isWordRightToLeft("!hello!", false); expect(result4).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); @@ -407,7 +421,7 @@ describe("string utils", () => { it("should handle cache clearing", () => { // Cache a result - Strings.getWordDirection("test", false); + Strings.isWordRightToLeft("test", false); expect(mapSetSpy).toHaveBeenCalledWith("test", false); // Clear cache @@ -419,14 +433,14 @@ describe("string utils", () => { mapClearSpy.mockClear(); // Should work normally after cache clear (cache miss again) - const result = Strings.getWordDirection("test", false); + const result = Strings.isWordRightToLeft("test", false); expect(result).toBe(false); expect(mapSetSpy).toHaveBeenCalledWith("test", false); }); it("should demonstrate cache miss vs cache hit behavior", () => { // Test cache miss - first time seeing this word - const result1 = Strings.getWordDirection("unique", false); + const result1 = Strings.isWordRightToLeft("unique", false); expect(result1).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("unique"); expect(mapSetSpy).toHaveBeenCalledWith("unique", false); @@ -435,7 +449,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); // Test cache hit - same word again - const result2 = Strings.getWordDirection("unique", false); + const result2 = Strings.isWordRightToLeft("unique", false); expect(result2).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("unique"); expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit @@ -444,7 +458,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); // Test cache miss - different word - const result3 = Strings.getWordDirection("different", false); + const result3 = Strings.isWordRightToLeft("different", false); expect(result3).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("different"); expect(mapSetSpy).toHaveBeenCalledWith("different", false); diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index edf6777639d4..72631e36668f 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -307,10 +307,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - .word { - //flex-direction: row-reverse; - direction: rtl; - } } &.withLigatures { .word { @@ -749,10 +745,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - .word { - //flex-direction: row-reverse; - direction: rtl; - } } &.withLigatures { .word { diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index c29dd3c28bf2..670c571f0c93 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -326,7 +326,6 @@ async function handleSpace(): Promise { void TestLogic.addWord(); } TestUI.updateActiveElement(); - void Caret.updatePosition(); const shouldLimitToThreeLines = Config.mode === "time" || @@ -344,8 +343,10 @@ async function handleSpace(): Promise { if ((nextTop ?? 0) > currentTop) { void TestUI.lineJump(currentTop); - } //end of line wrap - } + } + } //end of line wrap + + void Caret.updatePosition(); // enable if i decide that auto tab should also work after a space // if ( diff --git a/frontend/src/ts/elements/result-word-highlight.ts b/frontend/src/ts/elements/result-word-highlight.ts index 4d771db8c867..bfd4505b2bee 100644 --- a/frontend/src/ts/elements/result-word-highlight.ts +++ b/frontend/src/ts/elements/result-word-highlight.ts @@ -4,8 +4,7 @@ // Constants for padding around the highlights import * as Misc from "../utils/misc"; -import * as JSONData from "../utils/json-data"; -import Config from "../config"; +import * as TestState from "../test/test-state"; const PADDING_X = 16; const PADDING_Y = 12; @@ -56,7 +55,6 @@ let isInitialized = false; let isHoveringChart = false; let isFirstHighlightSinceInit = true; let isFirstHighlightSinceClear = true; -let isLanguageRightToLeft = false; let isInitInProgress = false; // Highlights .word elements in range [firstWordIndex, lastWordIndex] @@ -104,7 +102,7 @@ export async function highlightWordsInRange( const newHighlightElementPositions = getHighlightElementPositions( firstWordIndex, lastWordIndex, - isLanguageRightToLeft + TestState.isLanguageRightToLeft ); // For each line... @@ -198,10 +196,6 @@ async function init(): Promise { ); } - // Set isLanguageRTL - const currentLanguage = await JSONData.getCurrentLanguage(Config.language); - isLanguageRightToLeft = currentLanguage.rightToLeft ?? false; - RWH_el = $("#resultWordsHistory")[0] as HTMLElement; RWH_rect = RWH_el.getBoundingClientRect(); wordEls = $(RWH_el).find(".words .word[input]"); @@ -309,7 +303,7 @@ async function init(): Promise { // For RTL languages, account for difference between highlightContainer left and RWH_el left let RTL_offset; - if (isLanguageRightToLeft) { + if (TestState.isLanguageRightToLeft) { RTL_offset = line.rect.left - RWH_rect.left + PADDING_X; } else { RTL_offset = 0; diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index a8d0aab1b807..ce54653cf042 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,11 +1,10 @@ -import * as JSONData from "../utils/json-data"; import Config from "../config"; import * as TestInput from "./test-input"; import * as SlowTimer from "../states/slow-timer"; import * as TestState from "../test/test-state"; import * as TestWords from "./test-words"; import { convertRemToPixels } from "../utils/numbers"; -import { splitIntoCharacters, getWordDirection } from "../utils/strings"; +import { splitIntoCharacters, isWordRightToLeft } from "../utils/strings"; import { safeNumber } from "@monkeytype/util/numbers"; import { subscribe } from "../observables/config-event"; @@ -53,7 +52,6 @@ function getSpaceWidth(wordElement?: HTMLElement): number { function getTargetPositionLeft( fullWidthCaret: boolean, - isLanguageRightToLeft: boolean, activeWordElement: HTMLElement, currentWordNodeList: NodeListOf, fullWidthCaretWidth: number, @@ -65,9 +63,10 @@ function getTargetPositionLeft( let result = 0; // use word-specific direction if available and different from language direction - const isWordRightToLeft = getWordDirection( + const isWordRTL = isWordRightToLeft( currentWord, - isLanguageRightToLeft + TestState.isLanguageRightToLeft, + TestState.isDirectionReversed ); if (Config.tapeMode === "off") { @@ -77,7 +76,7 @@ function getTargetPositionLeft( const lastWordLetter = currentWordNodeList[wordLen - 1]; const lastInputLetter = currentWordNodeList[inputLen - 1]; - if (isWordRightToLeft) { + if (isWordRTL) { if (inputLen <= wordLen && currentLetter) { // at word beginning in zen mode both lengths are 0, but currentLetter is defined "_" positionOffsetToWord = @@ -110,13 +109,10 @@ function getTargetPositionLeft( $(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0; const tapeMargin = wordsWrapperWidth * - (isWordRightToLeft - ? 1 - Config.tapeMargin / 100 - : Config.tapeMargin / 100); + (isWordRTL ? 1 - Config.tapeMargin / 100 : Config.tapeMargin / 100); result = - tapeMargin - - (fullWidthCaret && isWordRightToLeft ? fullWidthCaretWidth : 0); + tapeMargin - (fullWidthCaret && isWordRTL ? fullWidthCaretWidth : 0); if (Config.tapeMode === "word" && inputLen > 0) { let currentWordWidth = 0; @@ -131,7 +127,7 @@ function getTargetPositionLeft( // 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 (isWordRightToLeft) currentWordWidth *= -1; + if (isWordRTL) currentWordWidth *= -1; result += currentWordWidth; } } @@ -185,9 +181,6 @@ export async function updatePosition(noAnim = false): Promise { const lastInputLetter = currentWordNodeList[inputLen - 1]; const lastWordLetter = currentWordNodeList[wordLen - 1]; - const currentLanguage = await JSONData.getCurrentLanguage(Config.language); - const isLanguageRightToLeft = currentLanguage.rightToLeft ?? false; - // in blind mode, and hide extra letters, extra letters have zero offsets // offsetHeight is the same for all visible letters // so is offsetTop (for same line letters) @@ -224,7 +217,6 @@ export async function updatePosition(noAnim = false): Promise { const letterPosLeft = getTargetPositionLeft( fullWidthCaret, - isLanguageRightToLeft, activeWordEl, currentWordNodeList, letterWidth, diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 77c8762ec8ef..a3f367c66195 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -4,12 +4,11 @@ import Config from "../config"; import * as DB from "../db"; import * as SlowTimer from "../states/slow-timer"; import * as Misc from "../utils/misc"; -import * as JSONData from "../utils/json-data"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; import { convertRemToPixels } from "../utils/numbers"; import { getActiveFunboxes } from "./funbox/list"; -import { getWordDirection } from "../utils/strings"; +import { isWordRightToLeft } from "../utils/strings"; type Settings = { wpm: number; @@ -51,22 +50,18 @@ async function resetCaretPosition(): Promise { if (firstLetter === undefined || firstLetterHeight === undefined) return; - const currentLanguage = await JSONData.getCurrentLanguage(Config.language); - const isLanguageRightToLeft = currentLanguage.rightToLeft; - const currentWord = TestWords.words.get(settings?.currentWordIndex ?? 0); - const isWordRightToLeft = getWordDirection( + const isWordRTL = isWordRightToLeft( currentWord, - isLanguageRightToLeft ?? false + TestState.isLanguageRightToLeft, + TestState.isDirectionReversed ); caret.stop(true, true).animate( { top: firstLetter.offsetTop - firstLetterHeight / 4, - left: - firstLetter.offsetLeft + - (isWordRightToLeft ? firstLetter.offsetWidth : 0), + left: firstLetter.offsetLeft + (isWordRTL ? firstLetter.offsetWidth : 0), }, 0, "linear" @@ -238,17 +233,14 @@ export async function update(expectedStepEnd: number): Promise { ); } - const currentLanguage = await JSONData.getCurrentLanguage( - Config.language - ); - const isLanguageRightToLeft = currentLanguage.rightToLeft; - const currentWord = TestWords.words.get(settings.currentWordIndex); - const isWordRightToLeft = getWordDirection( + const isWordRTL = isWordRightToLeft( currentWord, - isLanguageRightToLeft ?? false + TestState.isLanguageRightToLeft, + TestState.isDirectionReversed ); + newTop = word.offsetTop + currentLetter.offsetTop - @@ -258,13 +250,13 @@ export async function update(expectedStepEnd: number): Promise { word.offsetLeft + currentLetter.offsetLeft - caretWidth / 2 + - (isWordRightToLeft ? currentLetterWidth : 0); + (isWordRTL ? currentLetterWidth : 0); } else { newLeft = word.offsetLeft + currentLetter.offsetLeft - caretWidth / 2 + - (isWordRightToLeft ? 0 : currentLetterWidth); + (isWordRTL ? 0 : currentLetterWidth); } caret.removeClass("hidden"); } catch (e) { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index deddad44aad9..083028946b9c 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -70,6 +70,7 @@ import { getActiveFunboxes, getActiveFunboxesWithFunction, isFunboxActive, + isFunboxActiveWithProperty, } from "./funbox/list"; import { getFunbox } from "@monkeytype/funbox"; import * as CompositionState from "../states/composition"; @@ -408,7 +409,7 @@ let lastInitError: Error | null = null; let rememberLazyMode: boolean; let testReinitCount = 0; -export async function init(): Promise { +async function init(): Promise { console.debug("Initializing test"); testReinitCount++; if (testReinitCount > 3) { @@ -571,6 +572,13 @@ export async function init(): Promise { Funbox.toggleScript(TestWords.words.getCurrent()); TestUI.setRightToLeft(language.rightToLeft ?? false); TestUI.setLigatures(language.ligatures ?? false); + + const isLanguageRTL = language.rightToLeft ?? false; + TestState.setIsLanguageRightToLeft(isLanguageRTL); + TestState.setIsDirectionReversed( + isFunboxActiveWithProperty("reverseDirection") + ); + TestUI.showWords(); console.debug("Test initialized with words", generatedWords); console.debug( diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index c695b3e11d6b..97a066578e39 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -10,6 +10,8 @@ export let selectedQuoteId = 1; export let activeWordIndex = 0; export let testInitSuccess = true; export let lineScrollDistance = 0; +export let isLanguageRightToLeft = false; +export let isDirectionReversed = false; export function setRepeated(tf: boolean): void { isRepeated = tf; @@ -58,3 +60,11 @@ export function setTestInitSuccess(tf: boolean): void { export function setLineScrollDistance(val: number): void { lineScrollDistance = val; } + +export function setIsLanguageRightToLeft(rtl: boolean): void { + isLanguageRightToLeft = rtl; +} + +export function setIsDirectionReversed(val: boolean): void { + isDirectionReversed = val; +} diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 22ea7d322b15..efa1ffa421ee 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -229,7 +229,7 @@ export function updateActiveElement( activeWordTop = newActiveWord.offsetTop; - void updateWordsInputPosition(); + updateWordsInputPosition(); if (!initial && Config.tapeMode !== "off") { void scrollTape(); @@ -272,8 +272,11 @@ async function joinOverlappingHints( activeWordLetters: NodeListOf, hintElements: HTMLCollection ): Promise { - const currentLanguage = await JSONData.getCurrentLanguage(Config.language); - const isLanguageRTL = currentLanguage.rightToLeft; + const isWordRightToLeft = Strings.isWordRightToLeft( + TestWords.words.getCurrent(), + TestState.isLanguageRightToLeft, + TestState.isDirectionReversed + ); let previousBlocksAdjacent = false; let currentHintBlock = 0; @@ -305,8 +308,8 @@ async function joinOverlappingHints( const sameTop = block1Letter1.offsetTop === block2Letter1.offsetTop; - const leftBlock = isLanguageRTL ? hintBlock2 : hintBlock1; - const rightBlock = isLanguageRTL ? hintBlock1 : hintBlock2; + const leftBlock = isWordRightToLeft ? hintBlock2 : hintBlock1; + const rightBlock = isWordRightToLeft ? hintBlock1 : hintBlock2; // block edge is offset half its width because of transform: translate(-50%) const leftBlockEnds = leftBlock.offsetLeft + leftBlock.offsetWidth / 2; @@ -321,7 +324,7 @@ async function joinOverlappingHints( const block1Letter1Pos = block1Letter1.offsetLeft + - (isLanguageRTL ? block1Letter1.offsetWidth : 0); + (isWordRightToLeft ? block1Letter1.offsetWidth : 0); const bothBlocksLettersWidthHalved = hintBlock2.offsetLeft - hintBlock1.offsetLeft; hintBlock1.style.left = @@ -510,15 +513,16 @@ export function appendEmptyWordElement( ); } let updateWordsInputPositionAnimationFrameId: null | number = null; -export async function updateWordsInputPosition(): Promise { +export function updateWordsInputPosition(): void { if (updateWordsInputPositionAnimationFrameId !== null) { cancelAnimationFrame(updateWordsInputPositionAnimationFrameId); } - updateWordsInputPositionAnimationFrameId = requestAnimationFrame(async () => { + updateWordsInputPositionAnimationFrameId = requestAnimationFrame(() => { updateWordsInputPositionAnimationFrameId = null; if (ActivePage.get() !== "test") return; - const currentLanguage = await JSONData.getCurrentLanguage(Config.language); - const isLanguageRTL = currentLanguage.rightToLeft; + const isTestRightToLeft = TestState.isDirectionReversed + ? !TestState.isLanguageRightToLeft + : TestState.isLanguageRightToLeft; const el = document.querySelector("#wordsInput"); @@ -549,7 +553,7 @@ export async function updateWordsInputPosition(): Promise { el.style.top = targetTop + "px"; - if (activeWord.offsetWidth < letterHeight && isLanguageRTL) { + if (activeWord.offsetWidth < letterHeight && isTestRightToLeft) { el.style.left = activeWord.offsetLeft - letterHeight + "px"; } else { el.style.left = Math.max(0, activeWord.offsetLeft) + "px"; @@ -913,8 +917,9 @@ export async function scrollTape( await centeringActiveLine; - const currentLang = await JSONData.getCurrentLanguage(Config.language); - const isLanguageRTL = currentLang.rightToLeft; + const isTestRightToLeft = TestState.isDirectionReversed + ? !TestState.isLanguageRightToLeft + : TestState.isLanguageRightToLeft; const wordsWrapperWidth = ( document.querySelector("#wordsWrapper") as HTMLElement @@ -988,8 +993,8 @@ export async function scrollTape( const forWordLeft = Math.floor(child.offsetLeft); const forWordWidth = Math.floor(child.offsetWidth); if ( - (!isLanguageRTL && forWordLeft < 0 - forWordWidth) || - (isLanguageRTL && forWordLeft > wordsWrapperWidth) + (!isTestRightToLeft && forWordLeft < 0 - forWordWidth) || + (isTestRightToLeft && forWordLeft > wordsWrapperWidth) ) { toRemove.push(child); widthRemoved += wordOuterWidth; @@ -1035,7 +1040,7 @@ export async function scrollTape( currentLineIndent - (widthRemovedFromLine[i] ?? 0) }px`; } - if (isLanguageRTL) widthRemoved *= -1; + if (isTestRightToLeft) widthRemoved *= -1; const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0; wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`; } @@ -1068,7 +1073,7 @@ export async function scrollTape( wordsWrapperWidth * (Config.tapeMargin / 100) - wordsWidthBeforeActive - currentWordWidth; - if (isLanguageRTL) newMargin = wordRightMargin - newMargin; + if (isTestRightToLeft) newMargin = wordRightMargin - newMargin; const jqWords = $(wordsEl); if (Config.smoothLineScroll) { diff --git a/frontend/src/ts/ui.ts b/frontend/src/ts/ui.ts index 3afadcec582f..362d5e036b64 100644 --- a/frontend/src/ts/ui.ts +++ b/frontend/src/ts/ui.ts @@ -106,7 +106,7 @@ const debouncedEvent = debounce(250, () => { void TestUI.updateHintsPositionDebounced(); } setTimeout(() => { - void TestUI.updateWordsInputPosition(); + TestUI.updateWordsInputPosition(); TestUI.focusWords(); }, 250); } diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 264d54560633..0786bd3e41d0 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -236,26 +236,29 @@ export function clearWordDirectionCache(): void { wordDirectionCache.clear(); } -export function getWordDirection( +export function isWordRightToLeft( word: string | undefined, - languageRTL: boolean + languageRTL: boolean, + reverseDirection?: boolean ): boolean { - if (word === undefined || word.length === 0) return languageRTL; + if (word === undefined || word.length === 0) { + return reverseDirection ? !languageRTL : languageRTL; + } // Strip leading/trailing punctuation and whitespace so attached opposite-direction // punctuation like "word؟" or "،word" doesn't flip the direction detection // and if only punctuation/symbols/whitespace, use main language direction const core = word.replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, ""); - if (core.length === 0) return languageRTL; + if (core.length === 0) return reverseDirection ? !languageRTL : languageRTL; // cache by core to handle variants like "word" vs "word؟" const cached = wordDirectionCache.get(core); - if (cached !== undefined) return cached; + if (cached !== undefined) return reverseDirection ? !cached : cached; const result = hasRTLCharacters(core); wordDirectionCache.set(core, result); - return result; + return reverseDirection ? !result : result; } // Export testing utilities for unit tests diff --git a/frontend/static/funbox/backwards.css b/frontend/static/funbox/backwards.css index 2485d2af38a5..10d73f96a7e9 100644 --- a/frontend/static/funbox/backwards.css +++ b/frontend/static/funbox/backwards.css @@ -3,5 +3,9 @@ } #words.rightToLeftTest { - direction: rtl; + direction: ltr; +} + +#words.withLigatures .word { + unicode-bidi: bidi-override; } diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index eacce0e4b478..2f49d3728cf4 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -404,9 +404,9 @@ const list: Record = { name: "backwards", properties: [ "hasCssFile", - "noLigatures", "conflictsWithSymmetricChars", "wordOrder:reverse", + "reverseDirection", ], canGetPb: true, frontendFunctions: ["alterText"], diff --git a/packages/funbox/src/types.ts b/packages/funbox/src/types.ts index 0e0e99d30e29..104174ff3245 100644 --- a/packages/funbox/src/types.ts +++ b/packages/funbox/src/types.ts @@ -21,6 +21,7 @@ export type FunboxProperty = | "noLigatures" | `toPush:${number}` | "wordOrder:reverse" + | "reverseDirection" | "ignoreReducedMotion"; type FunboxCSSModification = "typingTest" | "words" | "body" | "main"; From 0b67868d56588b2fd7c5dac23828ef90736793ad Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 19 Sep 2025 23:05:48 +0200 Subject: [PATCH 3/3] fix: typo !nuf --- frontend/src/html/popups.html | 2 +- frontend/src/privacy-policy.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 9c78a4b600a5..6ee8facc89e8 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -178,7 +178,7 @@
sentry
We use Sentry to track errors and performance issues on our site, as - well as record annonymized user sessions to help us debug issues and + well as record anonymized user sessions to help us debug issues and improve our product.
diff --git a/frontend/src/privacy-policy.html b/frontend/src/privacy-policy.html index ebd57232ace6..c3c1b6539bc8 100644 --- a/frontend/src/privacy-policy.html +++ b/frontend/src/privacy-policy.html @@ -307,7 +307,7 @@

Sentry

Sentry is a crash reporting service that helps us track errors and crashes on the website. It collects information about your device, browser, and the error that occurred. Sometimes it might also include - an annonymized replay of your session. This information is used to + an anonymized replay of your session. This information is used to track down bugs faster and improve our website.