From 9708af6f8026f41f5d10531531becb7f66f37897 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:56 +0300 Subject: [PATCH 1/9] refactor Strings.isWordRightToLeft --- frontend/__tests__/utils/strings.spec.ts | 284 +++++++++-------------- frontend/src/ts/elements/caret.ts | 14 +- frontend/src/ts/test/test-logic.ts | 1 - frontend/src/ts/test/test-ui.ts | 14 +- frontend/src/ts/utils/direction-regex.ts | 5 + frontend/src/ts/utils/strings.ts | 93 +++----- 6 files changed, 171 insertions(+), 240 deletions(-) create mode 100644 frontend/src/ts/utils/direction-regex.ts diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 8fa02f4c5e81..7ccf405fa828 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -217,240 +217,182 @@ describe("string utils", () => { ); }); - describe("hasRTLCharacters", () => { + describe("getWordDirection", () => { it.each([ - // LTR characters should return false - [false, "hello", "basic Latin text"], - [false, "world123", "Latin text with numbers"], - [false, "test!", "Latin text with punctuation"], - [false, "ABC", "uppercase Latin text"], - [false, "", "empty string"], - [false, "123", "numbers only"], - [false, "!@#$%", "punctuation and symbols only"], - [false, " ", "whitespace only"], + // LTR characters should return "ltr" + ["ltr", "hello", "basic Latin text"], + ["ltr", "world123", "Latin text with numbers"], + ["ltr", "test!", "Latin text with punctuation"], + ["ltr", "ABC", "uppercase Latin text"], + ["ltr", "", "empty string"], + ["ltr", "123", "numbers only"], + ["ltr", "!@#$%", "punctuation and symbols only"], + ["ltr", " ", "whitespace only"], // Common LTR scripts - [false, "Здравствуй", "Cyrillic text"], - [false, "Bonjour", "Latin with accents"], - [false, "Καλημέρα", "Greek text"], - [false, "こんにちは", "Japanese Hiragana"], - [false, "你好", "Chinese characters"], - [false, "안녕하세요", "Korean text"], - - // RTL characters should return true - Arabic - [true, "مرحبا", "Arabic text"], - [true, "السلام", "Arabic phrase"], - [true, "العربية", "Arabic word"], - [true, "٠١٢٣٤٥٦٧٨٩", "Arabic-Indic digits"], - - // RTL characters should return true - Hebrew - [true, "שלום", "Hebrew text"], - [true, "עברית", "Hebrew word"], - [true, "ברוך", "Hebrew name"], - - // RTL characters should return true - Persian/Farsi - [true, "سلام", "Persian text"], - [true, "فارسی", "Persian word"], - - // Mixed content (should return true if ANY RTL characters are present) - [true, "hello مرحبا", "mixed LTR and Arabic"], - [true, "123 שלום", "numbers and Hebrew"], - [true, "test سلام!", "Latin, Persian, and punctuation"], - [true, "مرحبا123", "Arabic with numbers"], - [true, "hello؟", "Latin with Arabic punctuation"], + ["ltr", "Здравствуй", "Cyrillic text"], + ["ltr", "Bonjour", "Latin with accents"], + ["ltr", "Καλημέρα", "Greek text"], + ["ltr", "こんにちは", "Japanese Hiragana"], + ["ltr", "你好", "Chinese characters"], + ["ltr", "안녕하세요", "Korean text"], + + // strong RTL characters should return "rtl" - Arabic + ["rtl", "مرحبا", "Arabic text"], + ["rtl", "السلام", "Arabic phrase"], + ["rtl", "العربية", "Arabic word"], + + // digits without strong chars return fallback that defaults to ltr + ["ltr", "٠١٢٣٤٥٦٧٨٩", "Arabic-Indic digits with no strong typed chars"], + + // RTL characters should return "rtl" - Hebrew + ["rtl", "שלום", "Hebrew text"], + ["rtl", "עברית", "Hebrew word"], + ["rtl", "ברוך", "Hebrew name"], + + // RTL characters should return "rtl" - Persian/Farsi + ["rtl", "سلام", "Persian text"], + ["rtl", "فارسی", "Persian word"], + + // Mixed content (should return the direction of first strong character if there are both RTL and LTR characters + ["ltr", "hello مرحبا", "mixed LTR and Arabic"], + ["rtl", "123 שלום", "numbers and Hebrew"], + ["ltr", "test سلام!", "Latin, Persian, and punctuation"], + ["rtl", "مرحبا123", "Arabic with numbers"], + ["ltr", "hello؟", "Latin with Arabic punctuation"], // Edge cases with various Unicode ranges - [false, "𝕳𝖊𝖑𝖑𝖔", "mathematical bold text (LTR)"], - [false, "🌍🌎🌏", "emoji"], + ["ltr", "𝕳𝖊𝖑𝖑𝖔", "mathematical bold text (LTR)"], + ["ltr", "🌍🌎🌏", "emoji"], ] as const)( "should return %s for word '%s' (%s)", - (expected: boolean, word: string, _description: string) => { - expect(Strings.__testing.hasRTLCharacters(word)[0]).toBe(expected); + (expected: Strings.Direction, word: string, _description: string) => { + expect(Strings.getWordDirection(word)).toBe(expected); }, ); - }); - - describe("isWordRightToLeft", () => { - beforeEach(() => { - Strings.clearWordDirectionCache(); - }); it.each([ - // Basic functionality - should use hasRTLCharacters result when word has core content - [false, "hello", false, "LTR word in LTR language"], - [ - false, - "hello", - true, - "LTR word in RTL language (word direction overrides language)", - ], - [ - true, - "مرحبا", - false, - "RTL word in LTR language (word direction overrides language)", - ], - [true, "مرحبا", true, "RTL word in RTL language"], + // Basic functionality - should use regex pattern when word has core content + ["ltr", "hello", "ltr", "LTR word in LTR fallback"], + ["ltr", "hello", "rtl", "LTR word in RTL fallback"], + ["rtl", "مرحبا", "ltr", "RTL word in LTR fallback"], + ["rtl", "مرحبا", "rtl", "RTL word in RTL language"], // Punctuation stripping behavior - [false, "hello!", false, "LTR word with trailing punctuation"], - [false, "!hello", false, "LTR word with leading punctuation"], - [false, "!hello!", false, "LTR word with surrounding punctuation"], - [true, "مرحبا؟", false, "RTL word with trailing punctuation"], - [true, "؟مرحبا", false, "RTL word with leading punctuation"], - [true, "؟مرحبا؟", false, "RTL word with surrounding punctuation"], + ["ltr", "hello!", "ltr", "LTR word with trailing punctuation"], + ["ltr", "!hello", "ltr", "LTR word with leading punctuation"], + ["ltr", "!hello!", "ltr", "LTR word with surrounding punctuation"], + ["rtl", "مرحبا؟", "ltr", "RTL word with trailing punctuation"], + ["rtl", "؟مرحبا", "ltr", "RTL word with leading punctuation"], + ["rtl", "؟مرحبا؟", "ltr", "RTL word with surrounding punctuation"], // Fallback to language direction for empty/neutral content - [false, "", false, "empty string falls back to LTR language"], - [true, "", true, "empty string falls back to RTL language"], - [false, "!!!", false, "punctuation only falls back to LTR language"], - [true, "!!!", true, "punctuation only falls back to RTL language"], - [false, " ", false, "whitespace only falls back to LTR language"], - [true, " ", true, "whitespace only falls back to RTL language"], - - // Numbers behavior (numbers are neutral, follow hasRTLCharacters detection) - [false, "123", false, "regular digits are not RTL"], - [false, "123", true, "regular digits are not RTL regardless of language"], - [true, "١٢٣", false, "Arabic-Indic digits are detected as RTL"], - [true, "١٢٣", true, "Arabic-Indic digits are detected as RTL"], + ["ltr", "", "ltr", "empty string falls back to LTR"], + ["rtl", "", "rtl", "empty string falls back to RTL"], + ["ltr", "!!!", "ltr", "punctuation only falls back to LTR"], + ["rtl", "!!!", "rtl", "punctuation only falls back to RTL"], + ["ltr", " ", "ltr", "whitespace only falls back to LTR"], + ["rtl", " ", "rtl", "whitespace only falls back to RTL"], + + // Numbers behavior (numbers are neutral, follow regex detection) + [ + "ltr", + "123", + "ltr", + "regular digits with no strong typed chars should fallback to ltr", + ], + [ + "rtl", + "123", + "rtl", + "regular digits with no strong typed chars should fallback to rtl", + ], + [ + "ltr", + "١٢٣", + "ltr", + "Arabic-Indic digits with no strong typed chars should fallback to ltr", + ], + [ + "rtl", + "١٢٣", + "rtl", + "Arabic-Indic digits with no strong typed chars should fallback to rtl", + ], ] as const)( "should return %s for word '%s' with languageRTL=%s (%s)", ( - expected: boolean, + expected: Strings.Direction, word: string, - languageRTL: boolean, + fallback: Strings.Direction, _description: string, ) => { - expect(Strings.isWordRightToLeft(word, languageRTL)[0]).toBe(expected); + expect(Strings.getWordDirection(word, fallback)).toBe(expected); }, ); - it("should return languageRTL for undefined word", () => { - expect(Strings.isWordRightToLeft(undefined, false)[0]).toBe(false); - expect(Strings.isWordRightToLeft(undefined, true)[0]).toBe(true); + it("should return fallback direction for empty word", () => { + expect(Strings.getWordDirection(undefined, "ltr")).toBe("ltr"); + expect(Strings.getWordDirection(undefined, "rtl")).toBe("rtl"); }); - // testing reverseDirection - it("should return true for LTR word with reversed direction", () => { - expect(Strings.isWordRightToLeft("hello", false, true)[0]).toBe(true); - expect(Strings.isWordRightToLeft("hello", true, true)[0]).toBe(true); - }); - it("should return false for RTL word with reversed direction", () => { - expect(Strings.isWordRightToLeft("مرحبا", true, true)[0]).toBe(false); - expect(Strings.isWordRightToLeft("مرحبا", false, true)[0]).toBe(false); - }); - it("should return reverse of languageRTL for undefined word with reversed direction", () => { - expect(Strings.isWordRightToLeft(undefined, false, true)[0]).toBe(true); - expect(Strings.isWordRightToLeft(undefined, true, true)[0]).toBe(false); + it("should fallback to ltr", () => { + expect(Strings.getWordDirection()).toBe("ltr"); }); describe("caching", () => { let mapGetSpy: ReturnType; let mapSetSpy: ReturnType; - let mapClearSpy: ReturnType; beforeEach(() => { mapGetSpy = vi.spyOn(Map.prototype, "get"); mapSetSpy = vi.spyOn(Map.prototype, "set"); - mapClearSpy = vi.spyOn(Map.prototype, "clear"); }); afterEach(() => { mapGetSpy.mockRestore(); mapSetSpy.mockRestore(); - mapClearSpy.mockRestore(); }); it("should use cache for repeated calls", () => { // First call should cache the result (cache miss) - const result1 = Strings.isWordRightToLeft("hello", false); - expect(result1[0]).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("hello", [false, 0]); + const result1 = Strings.getWordDirection("firstCheck", "ltr"); + expect(result1).toBe("ltr"); + expect(mapSetSpy).toHaveBeenCalledWith("firstCheck", "ltr"); // Reset spies to check second call mapGetSpy.mockClear(); mapSetSpy.mockClear(); // Second call should use cache (cache hit) - const result2 = Strings.isWordRightToLeft("hello", false); - expect(result2[0]).toBe(false); - expect(mapGetSpy).toHaveBeenCalledWith("hello"); + const result2 = Strings.getWordDirection("firstCheck", "ltr"); + expect(result2).toBe("ltr"); + expect(mapGetSpy).toHaveBeenCalledWith("firstCheck"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again - // Cache should work regardless of language direction for same word + // Cache should work regardless of fallback direction for same word mapGetSpy.mockClear(); mapSetSpy.mockClear(); - const result3 = Strings.isWordRightToLeft("hello", true); - expect(result3[0]).toBe(false); // Still false because "hello" is LTR regardless of language - expect(mapGetSpy).toHaveBeenCalledWith("hello"); + const result3 = Strings.getWordDirection("firstCheck", "rtl"); + expect(result3).toBe("ltr"); // Still "ltr" because "hello" is LTR regardless of fallback + expect(mapGetSpy).toHaveBeenCalledWith("firstCheck"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again }); - it("should cache based on core word without punctuation", () => { - // First call should cache the result for core "hello" - const result1 = Strings.isWordRightToLeft("hello", false); - expect(result1[0]).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("hello", [false, 0]); - - mapGetSpy.mockClear(); - mapSetSpy.mockClear(); - - // These should all use the same cache entry since they have the same core - const result2 = Strings.isWordRightToLeft("hello!", false); - expect(result2[0]).toBe(false); - expect(mapGetSpy).toHaveBeenCalledWith("hello"); - expect(mapSetSpy).not.toHaveBeenCalled(); - - mapGetSpy.mockClear(); - mapSetSpy.mockClear(); - - const result3 = Strings.isWordRightToLeft("!hello", false); - expect(result3[0]).toBe(false); - expect(mapGetSpy).toHaveBeenCalledWith("hello"); - expect(mapSetSpy).not.toHaveBeenCalled(); - - mapGetSpy.mockClear(); - mapSetSpy.mockClear(); - - const result4 = Strings.isWordRightToLeft("!hello!", false); - expect(result4[0]).toBe(false); - expect(mapGetSpy).toHaveBeenCalledWith("hello"); - expect(mapSetSpy).not.toHaveBeenCalled(); - }); - - it("should handle cache clearing", () => { - // Cache a result - Strings.isWordRightToLeft("test", false); - expect(mapSetSpy).toHaveBeenCalledWith("test", [false, 0]); - - // Clear cache - Strings.clearWordDirectionCache(); - expect(mapClearSpy).toHaveBeenCalled(); - - mapGetSpy.mockClear(); - mapSetSpy.mockClear(); - mapClearSpy.mockClear(); - - // Should work normally after cache clear (cache miss again) - const result = Strings.isWordRightToLeft("test", false); - expect(result[0]).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("test", [false, 0]); - }); - it("should demonstrate cache miss vs cache hit behavior", () => { // Test cache miss - first time seeing this word - const result1 = Strings.isWordRightToLeft("unique", false); - expect(result1[0]).toBe(false); + const result1 = Strings.getWordDirection("unique", "ltr"); + expect(result1).toBe("ltr"); expect(mapGetSpy).toHaveBeenCalledWith("unique"); - expect(mapSetSpy).toHaveBeenCalledWith("unique", [false, 0]); + expect(mapSetSpy).toHaveBeenCalledWith("unique", "ltr"); mapGetSpy.mockClear(); mapSetSpy.mockClear(); // Test cache hit - same word again - const result2 = Strings.isWordRightToLeft("unique", false); - expect(result2[0]).toBe(false); + const result2 = Strings.getWordDirection("unique", "ltr"); + expect(result2).toBe("ltr"); expect(mapGetSpy).toHaveBeenCalledWith("unique"); expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit @@ -458,10 +400,10 @@ describe("string utils", () => { mapSetSpy.mockClear(); // Test cache miss - different word - const result3 = Strings.isWordRightToLeft("different", false); - expect(result3[0]).toBe(false); + const result3 = Strings.getWordDirection("different", "ltr"); + expect(result3).toBe("ltr"); expect(mapGetSpy).toHaveBeenCalledWith("different"); - expect(mapSetSpy).toHaveBeenCalledWith("different", [false, 0]); + expect(mapSetSpy).toHaveBeenCalledWith("different", "ltr"); }); }); }); diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 417c5ab8bd63..c1c63c53e8b3 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -2,7 +2,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft } from "../utils/strings"; +import { getWordDirection, reverseDirection } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -422,11 +422,13 @@ export class Caret { Config.mode === "zen" || Config.mode === "custom" || Config.funbox.includes("polyglot"); - const [isWordRTL, isFullMatch] = isWordRightToLeft( + let wordDirection = getWordDirection( checkRtlByLetter ? (letter.native.textContent ?? "") : options.wordText, - options.isLanguageRightToLeft, - options.isDirectionReversed, + options.isLanguageRightToLeft ? "rtl" : "ltr", ); + if (options.isDirectionReversed) { + wordDirection = reverseDirection(wordDirection); + } //if the letter is not visible, use the closest visible letter const isLetterVisible = letter.getOffsetWidth() > 0; @@ -458,8 +460,8 @@ export class Caret { wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100); // yes, this is all super verbose, but its easier to maintain and understand - if (isWordRTL) { - if (!checkRtlByLetter && isFullMatch) options.word.addClass("wordRtl"); + if (wordDirection === "rtl") { + if (!checkRtlByLetter) options.word.addClass("wordRtl"); let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3ce10c09a91a..dcaea32ed7c0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -187,7 +187,6 @@ export function restart(options = {} as RestartOptions): void { }; options = { ...defaultOptions, ...options }; - Strings.clearWordDirectionCache(); const animationTime = options.noAnim ? 0 : Misc.applyReducedMotion(125); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 71e3ca0fa80c..22409e06ce29 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -227,11 +227,13 @@ async function joinOverlappingHints( activeWordLetters: ElementsWithUtils, hintElements: HTMLCollection, ): Promise { - const [isWordRightToLeft] = Strings.isWordRightToLeft( + let wordDirection = Strings.getWordDirection( TestWords.words.getCurrent(), - TestState.isLanguageRightToLeft, - TestState.isDirectionReversed, + TestState.isLanguageRightToLeft ? "rtl" : "ltr", ); + if (TestState.isDirectionReversed) { + wordDirection = Strings.reverseDirection(wordDirection); + } let previousBlocksAdjacent = false; let currentHintBlock = 0; @@ -268,8 +270,8 @@ async function joinOverlappingHints( const sameTop = block1Letter1.getOffsetTop() === block2Letter1.getOffsetTop(); - const leftBlock = isWordRightToLeft ? hintBlock2 : hintBlock1; - const rightBlock = isWordRightToLeft ? hintBlock1 : hintBlock2; + const leftBlock = wordDirection === "ltr" ? hintBlock1 : hintBlock2; + const rightBlock = wordDirection === "ltr" ? hintBlock2 : hintBlock1; // block edge is offset half its width because of transform: translate(-50%) const leftBlockEnds = leftBlock.offsetLeft + leftBlock.offsetWidth / 2; @@ -284,7 +286,7 @@ async function joinOverlappingHints( const block1Letter1Pos = block1Letter1.getOffsetLeft() + - (isWordRightToLeft ? block1Letter1.getOffsetWidth() : 0); + (wordDirection === "ltr" ? 0 : block1Letter1.getOffsetWidth()); const bothBlocksLettersWidthHalved = hintBlock2.offsetLeft - hintBlock1.offsetLeft; hintBlock1.style.left = diff --git a/frontend/src/ts/utils/direction-regex.ts b/frontend/src/ts/utils/direction-regex.ts new file mode 100644 index 000000000000..bb8321c7f06a --- /dev/null +++ b/frontend/src/ts/utils/direction-regex.ts @@ -0,0 +1,5 @@ +// https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt +export const STRONG_RTL_TYPE = + /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F2\u05F3-\u05F4\u0608\u060B\u060D\u061B\u061C\u061D-\u061F\u0620-\u063F\u0640\u0641-\u064A\u066D\u066E-\u066F\u0671-\u06D3\u06D4\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u06FC\u06FD-\u06FE\u06FF\u0700-\u070D\u070F\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07C9\u07CA-\u07EA\u07F4-\u07F5\u07FA\u07FE-\u07FF\u0800-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u0887\u0888\u0889-\u088F\u08A0-\u08C8\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46-\uFB4F\uFB50-\uFBB1\uFBB2-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFDFC\uFE70-\uFE74\uFE76-\uFEFC\u{10800}-\u{10805}\u{10808}\u{1080A}-\u{10835}\u{10837}-\u{10838}\u{1083C}\u{1083F}-\u{10855}\u{10857}\u{10858}-\u{1085F}\u{10860}-\u{10876}\u{10877}-\u{10878}\u{10879}-\u{1087F}\u{10880}-\u{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}-\u{108F5}\u{108FB}-\u{108FF}\u{10900}-\u{10915}\u{10916}-\u{1091B}\u{10920}-\u{10939}\u{1093F}\u{10940}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109BD}\u{109BE}-\u{109BF}\u{109C0}-\u{109CF}\u{109D2}-\u{109FF}\u{10A00}\u{10A10}-\u{10A13}\u{10A15}-\u{10A17}\u{10A19}-\u{10A35}\u{10A40}-\u{10A48}\u{10A50}-\u{10A58}\u{10A60}-\u{10A7C}\u{10A7D}-\u{10A7E}\u{10A7F}\u{10A80}-\u{10A9C}\u{10A9D}-\u{10A9F}\u{10AC0}-\u{10AC7}\u{10AC8}\u{10AC9}-\u{10AE4}\u{10AEB}-\u{10AEF}\u{10AF0}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B5F}\u{10B60}-\u{10B72}\u{10B78}-\u{10B7F}\u{10B80}-\u{10B91}\u{10B99}-\u{10B9C}\u{10BA9}-\u{10BAF}\u{10C00}-\u{10C48}\u{10C80}-\u{10CB2}\u{10CC0}-\u{10CF2}\u{10CFA}-\u{10CFF}\u{10D00}-\u{10D23}\u{10D4A}-\u{10D4D}\u{10D4E}\u{10D4F}\u{10D50}-\u{10D65}\u{10D6F}\u{10D70}-\u{10D85}\u{10D8E}-\u{10D8F}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}-\u{10EB1}\u{10EC2}-\u{10EC4}\u{10EC5}\u{10EC6}-\u{10EC7}\u{10F00}-\u{10F1C}\u{10F1D}-\u{10F26}\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F54}\u{10F55}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\u{10FC4}\u{10FC5}-\u{10FCB}\u{10FE0}-\u{10FF6}\u{1E800}-\u{1E8C4}\u{1E8C7}-\u{1E8CF}\u{1E900}-\u{1E943}\u{1E94B}\u{1E950}-\u{1E959}\u{1E95E}-\u{1E95F}\u{1EC71}-\u{1ECAB}\u{1ECAC}\u{1ECAD}-\u{1ECAF}\u{1ECB0}\u{1ECB1}-\u{1ECB4}\u{1ED01}-\u{1ED2D}\u{1ED2E}\u{1ED2F}-\u{1ED3D}\u{1EE00}-\u{1EE03}\u{1EE05}-\u{1EE1F}\u{1EE21}-\u{1EE22}\u{1EE24}\u{1EE27}\u{1EE29}-\u{1EE32}\u{1EE34}-\u{1EE37}\u{1EE39}\u{1EE3B}\u{1EE42}\u{1EE47}\u{1EE49}\u{1EE4B}\u{1EE4D}-\u{1EE4F}\u{1EE51}-\u{1EE52}\u{1EE54}\u{1EE57}\u{1EE59}\u{1EE5B}\u{1EE5D}\u{1EE5F}\u{1EE61}-\u{1EE62}\u{1EE64}\u{1EE67}-\u{1EE6A}\u{1EE6C}-\u{1EE72}\u{1EE74}-\u{1EE77}\u{1EE79}-\u{1EE7C}\u{1EE7E}\u{1EE80}-\u{1EE89}\u{1EE8B}-\u{1EE9B}\u{1EEA1}-\u{1EEA3}\u{1EEA5}-\u{1EEA9}\u{1EEAB}-\u{1EEBB}]/u; +export const STRONG_LTR_TYPE = + /[\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u01BA\u01BB\u01BC-\u01BF\u01C0-\u01C3\u01C4-\u0293\u0294-\u0295\u0296-\u02AF\u02B0-\u02B8\u02BB-\u02C1\u02D0-\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376-\u0377\u037A\u037B-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u0482\u048A-\u052F\u0531-\u0556\u0559\u055A-\u055F\u0560-\u0588\u0589\u0903\u0904-\u0939\u093B\u093D\u093E-\u0940\u0949-\u094C\u094E-\u094F\u0950\u0958-\u0961\u0964-\u0965\u0966-\u096F\u0970\u0971\u0972-\u0980\u0982-\u0983\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09BE-\u09C0\u09C7-\u09C8\u09CB-\u09CC\u09CE\u09D7\u09DC-\u09DD\u09DF-\u09E1\u09E6-\u09EF\u09F0-\u09F1\u09F4-\u09F9\u09FA\u09FC\u09FD\u0A03\u0A05-\u0A0A\u0A0F-\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32-\u0A33\u0A35-\u0A36\u0A38-\u0A39\u0A3E-\u0A40\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A76\u0A83\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2-\u0AB3\u0AB5-\u0AB9\u0ABD\u0ABE-\u0AC0\u0AC9\u0ACB-\u0ACC\u0AD0\u0AE0-\u0AE1\u0AE6-\u0AEF\u0AF0\u0AF9\u0B02-\u0B03\u0B05-\u0B0C\u0B0F-\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32-\u0B33\u0B35-\u0B39\u0B3D\u0B3E\u0B40\u0B47-\u0B48\u0B4B-\u0B4C\u0B57\u0B5C-\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B70\u0B71\u0B72-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99-\u0B9A\u0B9C\u0B9E-\u0B9F\u0BA3-\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BBE-\u0BBF\u0BC1-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCC\u0BD0\u0BD7\u0BE6-\u0BEF\u0BF0-\u0BF2\u0C01-\u0C03\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C41-\u0C44\u0C58-\u0C5A\u0C5C-\u0C5D\u0C60-\u0C61\u0C66-\u0C6F\u0C77\u0C7F\u0C80\u0C82-\u0C83\u0C84\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CBE\u0CBF\u0CC0-\u0CC4\u0CC6\u0CC7-\u0CC8\u0CCA-\u0CCB\u0CD5-\u0CD6\u0CDC-\u0CDE\u0CE0-\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF2\u0CF3\u0D02-\u0D03\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D3E-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E\u0D4F\u0D54-\u0D56\u0D57\u0D58-\u0D5E\u0D5F-\u0D61\u0D66-\u0D6F\u0D70-\u0D78\u0D79\u0D7A-\u0D7F\u0D82-\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF3\u0DF4\u0E01-\u0E30\u0E32-\u0E33\u0E40-\u0E45\u0E46\u0E4F\u0E50-\u0E59\u0E5A-\u0E5B\u0E81-\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F01-\u0F03\u0F04-\u0F12\u0F13\u0F14\u0F15-\u0F17\u0F1A-\u0F1F\u0F20-\u0F29\u0F2A-\u0F33\u0F34\u0F36\u0F38\u0F3E-\u0F3F\u0F40-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FCF\u0FD0-\u0FD4\u0FD5-\u0FD8\u0FD9-\u0FDA\u1000-\u102A\u102B-\u102C\u1031\u1038\u103B-\u103C\u103F\u1040-\u1049\u104A-\u104F\u1050-\u1055\u1056-\u1057\u105A-\u105D\u1061\u1062-\u1064\u1065-\u1066\u1067-\u106D\u106E-\u1070\u1075-\u1081\u1083-\u1084\u1087-\u108C\u108E\u108F\u1090-\u1099\u109A-\u109C\u109E-\u109F\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FB\u10FC\u10FD-\u10FF\u1100-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1360-\u1368\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166D\u166E\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EB-\u16ED\u16EE-\u16F0\u16F1-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734\u1735-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7-\u17C8\u17D4-\u17D6\u17D7\u17D8-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1842\u1843\u1844-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930-\u1931\u1933-\u1938\u1946-\u194F\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19D9\u19DA\u1A00-\u1A16\u1A19-\u1A1A\u1A1E-\u1A1F\u1A20-\u1A54\u1A55\u1A57\u1A61\u1A63-\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AA6\u1AA7\u1AA8-\u1AAD\u1B04\u1B05-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B44\u1B45-\u1B4C\u1B4E-\u1B4F\u1B50-\u1B59\u1B5A-\u1B60\u1B61-\u1B6A\u1B74-\u1B7C\u1B7D-\u1B7F\u1B82\u1B83-\u1BA0\u1BA1\u1BA6-\u1BA7\u1BAA\u1BAE-\u1BAF\u1BB0-\u1BB9\u1BBA-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2-\u1BF3\u1BFC-\u1BFF\u1C00-\u1C23\u1C24-\u1C2B\u1C34-\u1C35\u1C3B-\u1C3F\u1C40-\u1C49\u1C4D-\u1C4F\u1C50-\u1C59\u1C5A-\u1C77\u1C78-\u1C7D\u1C7E-\u1C7F\u1C80-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CBF\u1CC0-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF6\u1CF7\u1CFA\u1D00-\u1D2B\u1D2C-\u1D6A\u1D6B-\u1D77\u1D78\u1D79-\u1D9A\u1D9B-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u200E\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2134\u2135-\u2138\u2139\u213C-\u213F\u2145-\u2149\u214E\u214F\u2160-\u2182\u2183-\u2184\u2185-\u2188\u2336-\u237A\u2395\u249C-\u24E9\u26AC\u2800-\u28FF\u2C00-\u2C7B\u2C7C-\u2C7D\u2C7E-\u2CE4\u2CEB-\u2CEE\u2CF2-\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D70\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u3005\u3006\u3007\u3021-\u3029\u302E-\u302F\u3031-\u3035\u3038-\u303A\u303B\u303C\u3041-\u3096\u309D-\u309E\u309F\u30A1-\u30FA\u30FC-\u30FE\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u3191\u3192-\u3195\u3196-\u319F\u31A0-\u31BF\u31F0-\u31FF\u3200-\u321C\u3220-\u3229\u322A-\u3247\u3248-\u324F\u3260-\u327B\u327F\u3280-\u3289\u328A-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA014\uA015\uA016-\uA48C\uA4D0-\uA4F7\uA4F8-\uA4FD\uA4FE-\uA4FF\uA500-\uA60B\uA60C\uA610-\uA61F\uA620-\uA629\uA62A-\uA62B\uA640-\uA66D\uA66E\uA680-\uA69B\uA69C-\uA69D\uA6A0-\uA6E5\uA6E6-\uA6EF\uA6F2-\uA6F7\uA722-\uA76F\uA770\uA771-\uA787\uA789-\uA78A\uA78B-\uA78E\uA78F\uA790-\uA7DC\uA7F1-\uA7F4\uA7F5-\uA7F6\uA7F7\uA7F8-\uA7F9\uA7FA\uA7FB-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA823-\uA824\uA827\uA830-\uA835\uA836-\uA837\uA840-\uA873\uA880-\uA881\uA882-\uA8B3\uA8B4-\uA8C3\uA8CE-\uA8CF\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8F8-\uA8FA\uA8FB\uA8FC\uA8FD-\uA8FE\uA900-\uA909\uA90A-\uA925\uA92E-\uA92F\uA930-\uA946\uA952-\uA953\uA95F\uA960-\uA97C\uA983\uA984-\uA9B2\uA9B4-\uA9B5\uA9BA-\uA9BB\uA9BE-\uA9C0\uA9C1-\uA9CD\uA9CF\uA9D0-\uA9D9\uA9DE-\uA9DF\uA9E0-\uA9E4\uA9E6\uA9E7-\uA9EF\uA9F0-\uA9F9\uA9FA-\uA9FE\uAA00-\uAA28\uAA2F-\uAA30\uAA33-\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA5F\uAA60-\uAA6F\uAA70\uAA71-\uAA76\uAA77-\uAA79\uAA7A\uAA7B\uAA7D\uAA7E-\uAAAF\uAAB1\uAAB5-\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADC\uAADD\uAADE-\uAADF\uAAE0-\uAAEA\uAAEB\uAAEE-\uAAEF\uAAF0-\uAAF1\uAAF2\uAAF3-\uAAF4\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5B\uAB5C-\uAB5F\uAB60-\uAB68\uAB69\uAB70-\uABBF\uABC0-\uABE2\uABE3-\uABE4\uABE6-\uABE7\uABE9-\uABEA\uABEB\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uE000-\uF8FF\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFF6F\uFF70\uFF71-\uFF9D\uFF9E-\uFF9F\uFFA0-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC\u{10000}-\u{1000B}\u{1000D}-\u{10026}\u{10028}-\u{1003A}\u{1003C}-\u{1003D}\u{1003F}-\u{1004D}\u{10050}-\u{1005D}\u{10080}-\u{100FA}\u{10100}\u{10102}\u{10107}-\u{10133}\u{10137}-\u{1013F}\u{1018D}-\u{1018E}\u{101D0}-\u{101FC}\u{10280}-\u{1029C}\u{102A0}-\u{102D0}\u{10300}-\u{1031F}\u{10320}-\u{10323}\u{1032D}-\u{10340}\u{10341}\u{10342}-\u{10349}\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}\u{103A0}-\u{103C3}\u{103C8}-\u{103CF}\u{103D0}\u{103D1}-\u{103D5}\u{10400}-\u{1044F}\u{10450}-\u{1049D}\u{104A0}-\u{104A9}\u{104B0}-\u{104D3}\u{104D8}-\u{104FB}\u{10500}-\u{10527}\u{10530}-\u{10563}\u{1056F}\u{10570}-\u{1057A}\u{1057C}-\u{1058A}\u{1058C}-\u{10592}\u{10594}-\u{10595}\u{10597}-\u{105A1}\u{105A3}-\u{105B1}\u{105B3}-\u{105B9}\u{105BB}-\u{105BC}\u{105C0}-\u{105F3}\u{10600}-\u{10736}\u{10740}-\u{10755}\u{10760}-\u{10767}\u{10780}-\u{10785}\u{10787}-\u{107B0}\u{107B2}-\u{107BA}\u{11000}\u{11002}\u{11003}-\u{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}-\u{11072}\u{11075}\u{11082}\u{11083}-\u{110AF}\u{110B0}-\u{110B2}\u{110B7}-\u{110B8}\u{110BB}-\u{110BC}\u{110BD}\u{110BE}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{1113F}\u{11140}-\u{11143}\u{11144}\u{11145}-\u{11146}\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11175}\u{11176}\u{11182}\u{11183}-\u{111B2}\u{111B3}-\u{111B5}\u{111BF}-\u{111C0}\u{111C1}-\u{111C4}\u{111C5}-\u{111C8}\u{111CD}\u{111CE}\u{111D0}-\u{111D9}\u{111DA}\u{111DB}\u{111DC}\u{111DD}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\u{1122B}\u{1122C}-\u{1122E}\u{11232}-\u{11233}\u{11235}\u{11238}-\u{1123D}\u{1123F}-\u{11240}\u{11280}-\u{11286}\u{11288}\u{1128A}-\u{1128D}\u{1128F}-\u{1129D}\u{1129F}-\u{112A8}\u{112A9}\u{112B0}-\u{112DE}\u{112E0}-\u{112E2}\u{112F0}-\u{112F9}\u{11302}-\u{11303}\u{11305}-\u{1130C}\u{1130F}-\u{11310}\u{11313}-\u{11328}\u{1132A}-\u{11330}\u{11332}-\u{11333}\u{11335}-\u{11339}\u{1133D}\u{1133E}-\u{1133F}\u{11341}-\u{11344}\u{11347}-\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11361}\u{11362}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}\u{113B8}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113CD}\u{113CF}\u{113D1}\u{113D3}\u{113D4}-\u{113D5}\u{113D7}-\u{113D8}\u{11400}-\u{11434}\u{11435}-\u{11437}\u{11440}-\u{11441}\u{11445}\u{11447}-\u{1144A}\u{1144B}-\u{1144F}\u{11450}-\u{11459}\u{1145A}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114AF}\u{114B0}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C5}\u{114C6}\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115AE}\u{115AF}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115D7}\u{115D8}-\u{115DB}\u{11600}-\u{1162F}\u{11630}-\u{11632}\u{1163B}-\u{1163C}\u{1163E}\u{11641}-\u{11643}\u{11644}\u{11650}-\u{11659}\u{11680}-\u{116AA}\u{116AC}\u{116AE}-\u{116AF}\u{116B6}\u{116B8}\u{116B9}\u{116C0}-\u{116C9}\u{116D0}-\u{116E3}\u{11700}-\u{1171A}\u{1171E}\u{11720}-\u{11721}\u{11726}\u{11730}-\u{11739}\u{1173A}-\u{1173B}\u{1173C}-\u{1173E}\u{1173F}\u{11740}-\u{11746}\u{11800}-\u{1182B}\u{1182C}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118DF}\u{118E0}-\u{118E9}\u{118EA}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}-\u{11916}\u{11918}-\u{1192F}\u{11930}-\u{11935}\u{11937}-\u{11938}\u{1193D}\u{1193F}\u{11940}\u{11941}\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D0}\u{119D1}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}\u{119E2}\u{119E3}\u{119E4}\u{11A00}\u{11A07}-\u{11A08}\u{11A0B}-\u{11A32}\u{11A39}\u{11A3A}\u{11A3F}-\u{11A46}\u{11A50}\u{11A57}-\u{11A58}\u{11A5C}-\u{11A89}\u{11A97}\u{11A9A}-\u{11A9C}\u{11A9D}\u{11A9E}-\u{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE0}\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2E}\u{11C2F}\u{11C3E}\u{11C3F}\u{11C40}\u{11C41}-\u{11C45}\u{11C50}-\u{11C59}\u{11C5A}-\u{11C6C}\u{11C70}-\u{11C71}\u{11C72}-\u{11C8F}\u{11CA9}\u{11CB1}\u{11CB4}\u{11D00}-\u{11D06}\u{11D08}-\u{11D09}\u{11D0B}-\u{11D30}\u{11D46}\u{11D50}-\u{11D59}\u{11D60}-\u{11D65}\u{11D67}-\u{11D68}\u{11D6A}-\u{11D89}\u{11D8A}-\u{11D8E}\u{11D93}-\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DD8}\u{11DD9}\u{11DDA}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF6}\u{11EF7}-\u{11EF8}\u{11F02}\u{11F03}\u{11F04}-\u{11F10}\u{11F12}-\u{11F33}\u{11F34}-\u{11F35}\u{11F3E}-\u{11F3F}\u{11F41}\u{11F43}-\u{11F4F}\u{11F50}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}\u{12000}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF0}\u{12FF1}-\u{12FF2}\u{13000}-\u{1342F}\u{13430}-\u{1343F}\u{13441}-\u{13446}\u{13460}-\u{143FA}\u{14400}-\u{14646}\u{16100}-\u{1611D}\u{1612A}-\u{1612C}\u{16130}-\u{16139}\u{16800}-\u{16A38}\u{16A40}-\u{16A5E}\u{16A60}-\u{16A69}\u{16A6E}-\u{16A6F}\u{16A70}-\u{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B3B}\u{16B3C}-\u{16B3F}\u{16B40}-\u{16B43}\u{16B44}\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D42}\u{16D43}-\u{16D6A}\u{16D6B}-\u{16D6C}\u{16D6D}-\u{16D6F}\u{16D70}-\u{16D79}\u{16E40}-\u{16E7F}\u{16E80}-\u{16E96}\u{16E97}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}\u{16F51}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}-\u{16FE1}\u{16FE3}\u{16FF0}-\u{16FF1}\u{16FF2}-\u{16FF3}\u{16FF4}-\u{16FF6}\u{17000}-\u{18CD5}\u{18CFF}-\u{18D1E}\u{18D80}-\u{18DF2}\u{1AFF0}-\u{1AFF3}\u{1AFF5}-\u{1AFFB}\u{1AFFD}-\u{1AFFE}\u{1B000}-\u{1B122}\u{1B132}\u{1B150}-\u{1B152}\u{1B155}\u{1B164}-\u{1B167}\u{1B170}-\u{1B2FB}\u{1BC00}-\u{1BC6A}\u{1BC70}-\u{1BC7C}\u{1BC80}-\u{1BC88}\u{1BC90}-\u{1BC99}\u{1BC9C}\u{1BC9F}\u{1CCD6}-\u{1CCEF}\u{1CF50}-\u{1CFC3}\u{1D000}-\u{1D0F5}\u{1D100}-\u{1D126}\u{1D129}-\u{1D164}\u{1D165}-\u{1D166}\u{1D16A}-\u{1D16C}\u{1D16D}-\u{1D172}\u{1D183}-\u{1D184}\u{1D18C}-\u{1D1A9}\u{1D1AE}-\u{1D1E8}\u{1D2C0}-\u{1D2D3}\u{1D2E0}-\u{1D2F3}\u{1D360}-\u{1D378}\u{1D400}-\u{1D454}\u{1D456}-\u{1D49C}\u{1D49E}-\u{1D49F}\u{1D4A2}\u{1D4A5}-\u{1D4A6}\u{1D4A9}-\u{1D4AC}\u{1D4AE}-\u{1D4B9}\u{1D4BB}\u{1D4BD}-\u{1D4C3}\u{1D4C5}-\u{1D505}\u{1D507}-\u{1D50A}\u{1D50D}-\u{1D514}\u{1D516}-\u{1D51C}\u{1D51E}-\u{1D539}\u{1D53B}-\u{1D53E}\u{1D540}-\u{1D544}\u{1D546}\u{1D54A}-\u{1D550}\u{1D552}-\u{1D6A5}\u{1D6A8}-\u{1D6C0}\u{1D6C2}-\u{1D6DA}\u{1D6DC}-\u{1D6FA}\u{1D6FC}-\u{1D714}\u{1D716}-\u{1D734}\u{1D736}-\u{1D74E}\u{1D750}-\u{1D76E}\u{1D770}-\u{1D788}\u{1D78A}-\u{1D7A8}\u{1D7AA}-\u{1D7C2}\u{1D7C4}-\u{1D7CB}\u{1D800}-\u{1D9FF}\u{1DA37}-\u{1DA3A}\u{1DA6D}-\u{1DA74}\u{1DA76}-\u{1DA83}\u{1DA85}-\u{1DA86}\u{1DA87}-\u{1DA8B}\u{1DF00}-\u{1DF09}\u{1DF0A}\u{1DF0B}-\u{1DF1E}\u{1DF25}-\u{1DF2A}\u{1E030}-\u{1E06D}\u{1E100}-\u{1E12C}\u{1E137}-\u{1E13D}\u{1E140}-\u{1E149}\u{1E14E}\u{1E14F}\u{1E290}-\u{1E2AD}\u{1E2C0}-\u{1E2EB}\u{1E2F0}-\u{1E2F9}\u{1E4D0}-\u{1E4EA}\u{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}\u{1E5F1}-\u{1E5FA}\u{1E5FF}\u{1E6C0}-\u{1E6DE}\u{1E6E0}-\u{1E6E2}\u{1E6E4}-\u{1E6E5}\u{1E6E7}-\u{1E6ED}\u{1E6F0}-\u{1E6F4}\u{1E6FE}\u{1E6FF}\u{1E7E0}-\u{1E7E6}\u{1E7E8}-\u{1E7EB}\u{1E7ED}-\u{1E7EE}\u{1E7F0}-\u{1E7FE}\u{1F110}-\u{1F12E}\u{1F130}-\u{1F169}\u{1F170}-\u{1F1AC}\u{1F1E6}-\u{1F202}\u{1F210}-\u{1F23B}\u{1F240}-\u{1F248}\u{1F250}-\u{1F251}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B81D}\u{2B820}-\u{2CEAD}\u{2CEB0}-\u{2EBE0}\u{2EBF0}-\u{2EE5D}\u{2F800}-\u{2FA1D}\u{30000}-\u{3134A}\u{31350}-\u{33479}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index a79458f176ea..284140c0bc4f 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -1,4 +1,5 @@ import { Language } from "@monkeytype/schemas/languages"; +import { STRONG_RTL_TYPE, STRONG_LTR_TYPE } from "./direction-regex"; /** * Removes accents from a string. @@ -215,67 +216,52 @@ export function replaceControlCharacters(textToClear: string): string { return textToClear; } -/** - * Detect if a word contains RTL (Right-to-Left) characters. - * This is for test scenarios where individual words may have different directions. - * Uses a simple regex pattern that covers all common RTL scripts. - * @param word the word to check for RTL characters - * @returns true if the word contains RTL characters, false otherwise - */ -function hasRTLCharacters(word: string): [boolean, number] { - if (!word || word.length === 0) { - return [false, 0]; - } +export type Direction = "ltr" | "rtl"; - // This covers Arabic, Farsi, Urdu, and other RTL scripts - const rtlPattern = - /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]+/; - - const result = rtlPattern.exec(word); - return [result !== null, result?.[0].length ?? 0]; -} +// Cache for word direction to avoid repeated calculations per word +const wordDirectionCache: Map = new Map(); /** - * Cache for word direction to avoid repeated calculations per word - * Keyed by the stripped core of the word; can be manually cleared when needed + * Get word direction based on the direction of its first character with + * strong type. + * @param word the word to check its direction. + * @param fallback direction to fallback on when the word is nullish or empty. + * @returns "ltr" or "rtl" depending on word's character content. */ -let wordDirectionCache: Map = new Map(); +export function getWordDirection( + word?: string, + fallback: Direction = "ltr", +): Direction { + if (word === undefined || word.length === 0) return fallback; -export function clearWordDirectionCache(): void { - wordDirectionCache.clear(); -} + const cachedDirection = wordDirectionCache.get(word); + if (cachedDirection !== undefined) return cachedDirection ?? fallback; -export function isWordRightToLeft( - word: string | undefined, - languageRTL: boolean, - reverseDirection?: boolean, -): [boolean, boolean] { - if (word === undefined || word.length === 0) { - return reverseDirection ? [!languageRTL, false] : [languageRTL, false]; - } + /* cache miss */ - // 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 reverseDirection ? [!languageRTL, false] : [languageRTL, false]; - } + const firstRTLChar = STRONG_RTL_TYPE.exec(word)?.index ?? Infinity; + const firstLTRChar = STRONG_LTR_TYPE.exec(word)?.index ?? Infinity; - // cache by core to handle variants like "word" vs "word؟" - const cached = wordDirectionCache.get(core); - if (cached !== undefined) { - return reverseDirection - ? [!cached[0], false] - : [cached[0], cached[1] === word.length]; - } + let direction: Direction | null; + // word has no characters with strong type, return fallback + if (firstRTLChar === Infinity && firstLTRChar === Infinity) direction = null; + // first char with strong type is rtl + else if (firstRTLChar < firstLTRChar) direction = "rtl"; + else direction = "ltr"; - const result = hasRTLCharacters(core); - wordDirectionCache.set(core, result); + wordDirectionCache.set(word, direction); + return direction ?? fallback; +} - return reverseDirection - ? [!result[0], false] - : [result[0], result[1] === word.length]; +/** + * Reverses "ltr" and "rtl" directions. Keeps it as is otherwise. + * @param direction direction to reverse + * @returns reversed direction. + */ +export function reverseDirection(direction: Direction): Direction { + if (direction === "ltr") return "rtl"; + else if (direction === "rtl") return "ltr"; + else return direction; } export const CHAR_EQUIVALENCE_SETS = [ @@ -376,8 +362,3 @@ export function isSpace(char: string): boolean { return spaces.has(codePoint); } - -// Export testing utilities for unit tests -export const __testing = { - hasRTLCharacters, -}; From 8133b5c6bb5141bf531d422577703686c50174d9 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:56 +0300 Subject: [PATCH 2/9] move koreanStatus to test-words instead of test-input --- frontend/src/ts/test/test-input.ts | 10 ---------- frontend/src/ts/test/test-logic.ts | 3 +-- frontend/src/ts/test/test-stats.ts | 6 +++--- frontend/src/ts/test/test-words.ts | 3 +++ 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index c58e088a75d9..228fd3d6a47b 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -99,11 +99,9 @@ type ErrorHistoryObject = { class Input { current: string; private history: string[]; - koreanStatus: boolean; constructor() { this.current = ""; this.history = []; - this.koreanStatus = false; } reset(): void { @@ -115,14 +113,6 @@ class Input { this.history = []; } - setKoreanStatus(val: boolean): void { - this.koreanStatus = val; - } - - getKoreanStatus(): boolean { - return this.koreanStatus; - } - pushHistory(): void { this.history.push(this.current); this.current = ""; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index dcaea32ed7c0..d704dfb45bd2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -305,7 +305,6 @@ export function restart(options = {} as RestartOptions): void { TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - TestInput.input.setKoreanStatus(false); clearQuoteStats(); CompositionState.setComposing(false); CompositionState.setData(""); @@ -566,7 +565,7 @@ async function init(): Promise { /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g, ) ) { - TestInput.input.setKoreanStatus(true); + TestWords.words.koreanStatus = true; } for (let i = 0; i < generatedWords.length; i++) { diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 392683b16781..f3f4fde3abe0 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -213,7 +213,7 @@ export function setLastSecondNotRound(): void { } export function calculateBurst(endTime: number = performance.now()): number { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestWords.words.koreanStatus; const timeToWrite = (endTime - TestInput.currentBurstStart) / 1000; if (timeToWrite <= 0) return 0; let wordLength: number; @@ -247,7 +247,7 @@ export function removeAfkData(): void { } function getInputWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestWords.words.koreanStatus; let inputWords = [...TestInput.input.getHistory()]; @@ -263,7 +263,7 @@ function getInputWords(): string[] { } function getTargetWords(): string[] { - const containsKorean = TestInput.input.getKoreanStatus(); + const containsKorean = TestWords.words.koreanStatus; let targetWords = [ ...(Config.mode === "zen" diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 1f618e0fd0fe..745ab751df6c 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -5,11 +5,13 @@ class Words { public list: string[]; public sectionIndexList: number[]; public length: number; + public koreanStatus: boolean; constructor() { this.list = []; this.sectionIndexList = []; this.length = 0; + this.koreanStatus = false; } get(i?: undefined, raw?: boolean): string[]; @@ -41,6 +43,7 @@ class Words { this.list = []; this.sectionIndexList = []; this.length = this.list.length; + this.koreanStatus = false; } clean(): void { for (const s of this.list) { From baad0c1870a4ba1ab2d8f378b2b7f756a39e0d36 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:57 +0300 Subject: [PATCH 3/9] move words.hasNumbers to inside the TestWords.words class also, test the whole wordset for numbers instead of only generated words --- frontend/src/ts/elements/keymap.ts | 2 +- frontend/src/ts/test/test-logic.ts | 13 ++++--------- frontend/src/ts/test/test-words.ts | 9 ++++----- frontend/src/ts/test/words-generator.ts | 3 +++ 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index cdb305d95090..e4bb0b8bf181 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -431,7 +431,7 @@ export async function refresh(): Promise { } const showTopRow = - (TestWords.hasNumbers && Config.keymapMode === "next") || + (TestWords.words.haveNumbers && Config.keymapMode === "next") || Config.keymapShowTopRow === "always" || (layoutData.keymapShowTopRow && Config.keymapShowTopRow !== "never"); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index d704dfb45bd2..8b274db76a72 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -510,6 +510,7 @@ async function init(): Promise { currentQuote: TestWords.currentQuote, }); + let wordsHaveNumbers = false; let wordsHaveTab = false; let wordsHaveNewline = false; let allRightToLeft: boolean | undefined = undefined; @@ -520,8 +521,10 @@ async function init(): Promise { const gen = await WordsGenerator.generateWords(language); generatedWords = gen.words; generatedSectionIndexes = gen.sectionIndexes; + wordsHaveNumbers = gen.hasNumbers; wordsHaveTab = gen.hasTab; wordsHaveNewline = gen.hasNewline; + ({ allRightToLeft, allLigatures } = gen); } catch (e) { hideLoaderBar(); @@ -545,15 +548,7 @@ async function init(): Promise { return await init(); } - let hasNumbers = false; - - for (const word of generatedWords) { - if (/\d/g.test(word) && !hasNumbers) { - hasNumbers = true; - } - } - - TestWords.setHasNumbers(hasNumbers); + TestWords.words.haveNumbers = wordsHaveNumbers; setWordsHaveTab(wordsHaveTab); setWordsHaveNewline(wordsHaveNewline); diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 745ab751df6c..a007e7406f18 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -5,12 +5,14 @@ class Words { public list: string[]; public sectionIndexList: number[]; public length: number; + public haveNumbers: boolean; public koreanStatus: boolean; constructor() { this.list = []; this.sectionIndexList = []; this.length = 0; + this.haveNumbers = false; this.koreanStatus = false; } @@ -43,6 +45,7 @@ class Words { this.list = []; this.sectionIndexList = []; this.length = this.list.length; + this.haveNumbers = false; this.koreanStatus = false; } clean(): void { @@ -60,13 +63,9 @@ class Words { } export const words = new Words(); -export let hasNumbers = false; + export let currentQuote = null as QuoteWithTextSplit | null; export function setCurrentQuote(rq: QuoteWithTextSplit | null): void { currentQuote = rq; } - -export function setHasNumbers(tf: boolean): void { - hasNumbers = tf; -} diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 4302df510bfe..6eff67968c9e 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -601,6 +601,7 @@ type GenerateWordsReturn = { sectionIndexes: number[]; hasTab: boolean; hasNewline: boolean; + hasNumbers: boolean; allRightToLeft?: boolean; allLigatures?: boolean; }; @@ -624,6 +625,7 @@ export async function generateWords( sectionIndexes: [], hasTab: false, hasNewline: false, + hasNumbers: false, allRightToLeft: language.rightToLeft, allLigatures: language.ligatures ?? false, }; @@ -721,6 +723,7 @@ export async function generateWords( currentWordset.words.some((w) => w.includes("\n")) || (Config.mode === "quote" && (quote as QuoteWithTextSplit).textSplit.some((w) => w.includes("\n"))); + ret.hasNumbers = currentWordset.words.some((w) => /\d/g.test(w)); sectionHistory = []; //free up a bit of memory? is that even a thing? return ret; From 7e1e6abc703b41a68195e792b59efc6009336332 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:57 +0300 Subject: [PATCH 4/9] move words.hasNewlines and words.hasTabs to inside the TestWords class also, simplify the test for tabs and newlines --- frontend/src/ts/test/test-logic.ts | 2 ++ frontend/src/ts/test/test-words.ts | 6 ++++++ frontend/src/ts/test/words-generator.ts | 12 ++---------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8b274db76a72..3dc893eb9569 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -549,6 +549,8 @@ async function init(): Promise { } TestWords.words.haveNumbers = wordsHaveNumbers; + TestWords.words.haveNewlines = wordsHaveNewline; + TestWords.words.haveTabs = wordsHaveTab; setWordsHaveTab(wordsHaveTab); setWordsHaveNewline(wordsHaveNewline); diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index a007e7406f18..4256c6417d96 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -6,6 +6,8 @@ class Words { public sectionIndexList: number[]; public length: number; public haveNumbers: boolean; + public haveNewlines: boolean; + public haveTabs: boolean; public koreanStatus: boolean; constructor() { @@ -13,6 +15,8 @@ class Words { this.sectionIndexList = []; this.length = 0; this.haveNumbers = false; + this.haveNewlines = false; + this.haveTabs = false; this.koreanStatus = false; } @@ -46,6 +50,8 @@ class Words { this.sectionIndexList = []; this.length = this.list.length; this.haveNumbers = false; + this.haveNewlines = false; + this.haveTabs = false; this.koreanStatus = false; } clean(): void { diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 6eff67968c9e..1a42a1f84d26 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -713,16 +713,8 @@ export async function generateWords( throw new WordGenError("Random quote is null"); } - ret.hasTab = - ret.words.some((w) => w.includes("\t")) || - currentWordset.words.some((w) => w.includes("\t")) || - (Config.mode === "quote" && - (quote as QuoteWithTextSplit).textSplit.some((w) => w.includes("\t"))); - ret.hasNewline = - ret.words.some((w) => w.includes("\n")) || - currentWordset.words.some((w) => w.includes("\n")) || - (Config.mode === "quote" && - (quote as QuoteWithTextSplit).textSplit.some((w) => w.includes("\n"))); + ret.hasTab = currentWordset.words.some((w) => w.includes("\t")); + ret.hasNewline = currentWordset.words.some((w) => w.includes("\n")); ret.hasNumbers = currentWordset.words.some((w) => /\d/g.test(w)); sectionHistory = []; //free up a bit of memory? is that even a thing? From 2aaa92f58b7e886515f4bea0649f00937f25539b Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:57 +0300 Subject: [PATCH 5/9] add direction property to test words --- .../src/ts/commandline/lists/result-screen.ts | 4 +- frontend/src/ts/elements/caret.ts | 6 +- .../src/ts/input/handlers/before-delete.ts | 2 +- .../ts/input/handlers/before-insert-text.ts | 6 +- frontend/src/ts/input/handlers/delete.ts | 4 +- frontend/src/ts/input/handlers/insert-text.ts | 10 +-- .../src/ts/input/helpers/word-navigation.ts | 6 +- frontend/src/ts/input/listeners/input.ts | 2 +- .../src/ts/test/funbox/funbox-functions.ts | 8 +- frontend/src/ts/test/pace-caret.ts | 84 ++++++++++--------- frontend/src/ts/test/practise-words.ts | 6 +- frontend/src/ts/test/test-logic.ts | 72 +++++++--------- frontend/src/ts/test/test-stats.ts | 11 ++- frontend/src/ts/test/test-timer.ts | 5 +- frontend/src/ts/test/test-ui.ts | 14 ++-- frontend/src/ts/test/test-words.ts | 82 ++++++++++-------- frontend/src/ts/test/timer-progress.ts | 5 +- frontend/src/ts/test/words-generator.ts | 48 ++++++----- 18 files changed, 201 insertions(+), 174 deletions(-) diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index 1debee3ac804..c2f406983ce5 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -142,7 +142,9 @@ const commands: Command[] = [ const words = ( Config.mode === "zen" ? TestInput.input.getHistory() - : TestWords.words.list.slice(0, TestInput.input.getHistory().length) + : TestWords.words + .getText() + .slice(0, TestInput.input.getHistory().length) ).join(" "); navigator.clipboard.writeText(words).then( diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index c1c63c53e8b3..00cb2aff9116 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -2,7 +2,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "../utils/misc"; -import { getWordDirection, reverseDirection } from "../utils/strings"; +import { splitIntoCharacters, getWordDirection, reverseDirection } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -287,8 +287,8 @@ export class Caret { const word = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = TestWords.words.get(options.wordIndex) ?? ""; - const wordLength = Array.from(wordText).length; + const wordText = TestWords.words.getText(options.wordIndex) ?? ""; + const wordLength = splitIntoCharacters(wordText).length; // caret can be either on the left side of the target letter or the right // we stick to the left side unless we are on the last letter or beyond diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index 12784dfdacb0..867a2525dc2e 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -43,7 +43,7 @@ export function onBeforeDelete(event: InputEvent): void { const confidence = Config.confidenceMode; const previousWordCorrect = (TestInput.input.get(TestState.activeWordIndex - 1) ?? "") === - TestWords.words.get(TestState.activeWordIndex - 1); + TestWords.words.getText(TestState.activeWordIndex - 1); if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { event.preventDefault(); diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 9c582fd28e3c..1bcc44aa3172 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -34,7 +34,7 @@ export function onBeforeInsertText(data: string): boolean { const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ data, inputValue, - targetWord: TestWords.words.getCurrent(), + targetWord: TestWords.words.getCurrentText(), }); //prevent space from being inserted if input is empty @@ -60,7 +60,7 @@ export function onBeforeInsertText(data: string): boolean { // block input if the word is too long const inputLimit = - Config.mode === "zen" ? 30 : TestWords.words.getCurrent().length + 20; + Config.mode === "zen" ? 30 : TestWords.words.getCurrentText().length + 20; const overLimit = TestInput.input.current.length >= inputLimit; if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { console.error("Hitting word limit"); @@ -71,7 +71,7 @@ export function onBeforeInsertText(data: string): boolean { // this will not work for the first word of each line, but that has a low chance of happening const dataIsNotFalsy = data !== null && data !== ""; const inputIsLongerThanOrEqualToWord = - TestInput.input.current.length >= TestWords.words.getCurrent().length; + TestInput.input.current.length >= TestWords.words.getCurrentText().length; if ( !SlowTimer.get() && // don't do this check if slow timer is active diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts index 0ee4213d8e3f..932b19b4c1eb 100644 --- a/frontend/src/ts/input/handlers/delete.ts +++ b/frontend/src/ts/input/handlers/delete.ts @@ -20,7 +20,7 @@ export function onDelete(inputType: DeleteInputType): void { const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); const allTabsCorrect = TestWords.words - .getCurrent() + .getCurrentText() .startsWith(TestInput.input.current); //special check for code languages @@ -31,7 +31,7 @@ export function onDelete(inputType: DeleteInputType): void { beforeDeleteOnlyTabs && allTabsCorrect // (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - // TestWords.words.get(TestState.activeWordIndex - 1) || + // TestWords.words.getText(TestState.activeWordIndex - 1) || // Config.freedomMode) ) { setInputElementValue(""); diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 6c0eef3c833b..ff19d06333e9 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -85,7 +85,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { const charOverride = charOverrides.get(options.data); if ( charOverride !== undefined && - TestWords.words.getCurrent()[TestInput.input.current.length] !== + TestWords.words.getCurrentText()[TestInput.input.current.length] !== options.data ) { // replace the data with the override @@ -101,7 +101,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // input and target word const testInput = TestInput.input.current; - const currentWord = TestWords.words.getCurrent(); + const currentWord = TestWords.words.getCurrentText(); // if the character is visually equal, replace it with the target character // this ensures all future equivalence checks work correctly @@ -151,7 +151,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // word navigation check const noSpaceForce = isFunboxActiveWithProperty("nospace") && - (testInput + data).length === TestWords.words.getCurrent().length; + (testInput + data).length === TestWords.words.getCurrentText().length; const shouldGoToNextWord = ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; @@ -169,7 +169,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestInput.pushKeypressWord(wordIndex); if (!correct) { TestInput.incrementKeypressErrors(); - TestInput.pushMissedWord(TestWords.words.getCurrent()); + TestInput.pushMissedWord(TestWords.words.getCurrentText()); } if (Config.keymapMode === "react") { flash(data, correct); @@ -236,7 +236,7 @@ export async function onInsertText(options: OnInsertTextParams): Promise { */ //this COULD be the next word because we are awaiting goToNextWord - const nextWord = TestWords.words.getCurrent(); + const nextWord = TestWords.words.getCurrentText(); const doesNextWordHaveTab = /^\t+/.test(nextWord); const isCurrentCharTab = nextWord[TestInput.input.current.length] === "\t"; diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts index fdb0e298bca2..897d115e65c0 100644 --- a/frontend/src/ts/input/helpers/word-navigation.ts +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -60,9 +60,9 @@ export async function goToNextWord({ TestInput.pushBurstToHistory(burst); ret.lastBurst = burst; - PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrent()); + PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrentText()); - Funbox.toggleScript(TestWords.words.get(TestState.activeWordIndex + 1)); + Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex + 1)); TestInput.input.pushHistory(); TestInput.corrected.pushHistory(); @@ -111,7 +111,7 @@ export function goToPreviousWord( TestState.decreaseActiveWordIndex(); TestInput.corrected.popHistory(); - Funbox.toggleScript(TestWords.words.get(TestState.activeWordIndex)); + Funbox.toggleScript(TestWords.words.getText(TestState.activeWordIndex)); const nospaceEnabled = isFunboxActiveWithProperty("nospace"); diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts index 51855bc55c34..cc2b884d4dcf 100644 --- a/frontend/src/ts/input/listeners/input.ts +++ b/frontend/src/ts/input/listeners/input.ts @@ -125,7 +125,7 @@ inputEl.addEventListener("input", async (event) => { const inputPlusComposition = TestInput.input.current + (CompositionState.getData() ?? ""); const inputPlusCompositionIsCorrect = - TestWords.words.getCurrent() === inputPlusComposition; + TestWords.words.getCurrentText() === inputPlusComposition; // composition quick end // if the user typed the entire word correctly but is still in composition diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 1c65da0b870a..00f4b9e724ec 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -54,7 +54,7 @@ export type FunboxFunctions = { async function readAheadHandleKeydown(event: KeyboardEvent): Promise { const inputCurrentChar = (TestInput.input.current ?? "").slice(-1); const wordCurrentChar = TestWords.words - .getCurrent() + .getCurrentText() .slice(TestInput.input.current.length - 1, TestInput.input.current.length); const isCorrect = inputCurrentChar === wordCurrentChar; @@ -63,7 +63,7 @@ async function readAheadHandleKeydown(event: KeyboardEvent): Promise { !isCorrect && (TestInput.input.current !== "" || TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - TestWords.words.get(TestState.activeWordIndex - 1) || + TestWords.words.getText(TestState.activeWordIndex - 1) || Config.freedomMode) ) { qs("#words")?.addClass("read_ahead_disabled"); @@ -452,7 +452,9 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words.getCurrent().charAt(TestInput.input.current.length), + Strings.splitIntoCharacters(TestWords.words.getCurrentText())[ + TestInput.input.current.length + ] as string, ); }, 1); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index d934c71c0264..f2ccdeda22ac 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -191,50 +191,58 @@ export function reset(): void { function incrementLetterIndex(): void { if (settings === null) return; - try { - settings.currentLetterIndex++; - if ( - settings.currentLetterIndex >= - TestWords.words.get(settings.currentWordIndex).length + 1 - ) { - //go to the next word - settings.currentLetterIndex = 0; - settings.currentWordIndex++; - } - if (!Config.blindMode) { - if (settings.correction < 0) { - while (settings.correction < 0) { - settings.currentLetterIndex--; - if (settings.currentLetterIndex <= -2) { - //go to the previous word - settings.currentLetterIndex = - TestWords.words.get(settings.currentWordIndex - 1).length - 1; - settings.currentWordIndex--; - } - settings.correction++; - } - } else if (settings.correction > 0) { - while (settings.correction > 0) { - settings.currentLetterIndex++; - if ( - settings.currentLetterIndex >= - TestWords.words.get(settings.currentWordIndex).length - ) { - //go to the next word - settings.currentLetterIndex = 0; - settings.currentWordIndex++; - } - settings.correction--; - } - } - } - } catch (e) { + const finish = (): void => { //out of words settings = null; console.log("pace caret out of words"); caret.hide(); + }; + + settings.currentLetterIndex++; + + const currentWord = TestWords.words.get(settings.currentWordIndex); + if (currentWord === undefined) { + finish(); return; } + + if (settings.currentLetterIndex > currentWord.text.length) { + //go to the next word + settings.currentLetterIndex = 0; + settings.currentWordIndex++; + } + + if (Config.blindMode) return; + + while (settings.correction < 0) { + settings.currentLetterIndex--; + if (settings.currentLetterIndex < 0) { + //go to the previous word + const previousWord = TestWords.words.get(settings.currentWordIndex - 1); + if (previousWord === undefined) { + finish(); + return; + } + settings.currentLetterIndex = previousWord.text.length; + settings.currentWordIndex--; + } + settings.correction++; + } + + while (settings.correction > 0) { + settings.currentLetterIndex++; + const currentWord = TestWords.words.get(settings.currentWordIndex); + if (currentWord === undefined) { + finish(); + return; + } + if (settings.currentLetterIndex > currentWord.text.length) { + //go to the next word + settings.currentLetterIndex = 0; + settings.currentWordIndex++; + } + settings.correction--; + } } export function handleSpace(correct: boolean, currentWord: string): void { diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 6bb97b5e023d..912f630faf99 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -55,7 +55,7 @@ export function init( let sortableMissedBiwords: [string, string, number][] = []; if (missed === "biwords") { for (let i = 0; i < TestWords.words.length; i++) { - const missedWord = TestWords.words.get(i); + const missedWord = TestWords.words.getText(i); const missedWordCount = TestInput.missedWords[missedWord]; if (missedWordCount !== undefined) { if (i === 0) { @@ -63,7 +63,7 @@ export function init( } else { sortableMissedBiwords.push([ missedWord, - TestWords.words.get(i - 1), + TestWords.words.getText(i - 1), missedWordCount, ]); } @@ -87,7 +87,7 @@ export function init( let sortableSlowWords: [string, number][] = []; if (slow) { const typedWords = TestWords.words - .get() + .getText() .slice(0, TestInput.input.getHistory().length - 1); sortableSlowWords = typedWords.map((e, i) => [ diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 3dc893eb9569..588aec4fe029 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -2,7 +2,6 @@ import Ape from "../ape"; import * as TestUI from "./test-ui"; import * as Strings from "../utils/strings"; import * as Misc from "../utils/misc"; -import * as Arrays from "../utils/arrays"; import * as JSONData from "../utils/json-data"; import * as Numbers from "@monkeytype/util/numbers"; import { @@ -58,7 +57,6 @@ import { getActiveFunboxesWithFunction, getActiveFunboxNames, isFunboxActive, - isFunboxActiveWithProperty, } from "./funbox/list"; import { getFunbox } from "@monkeytype/funbox"; import * as CompositionState from "../legacy-states/composition"; @@ -144,7 +142,7 @@ export function startTest(now: number): boolean { TestState.setActive(true); Replay.startReplayRecording(); - Replay.replayGetWordsList(TestWords.words.list); + Replay.replayGetWordsList(TestWords.words.getText()); TestInput.carryoverFirstKeypress(); Time.set(0); TestTimer.clear(); @@ -513,19 +511,19 @@ async function init(): Promise { let wordsHaveNumbers = false; let wordsHaveTab = false; let wordsHaveNewline = false; - let allRightToLeft: boolean | undefined = undefined; let allLigatures: boolean | undefined = undefined; - let generatedWords: string[] = []; + let generatedWords: TestWords.Word[] = []; + let generatedWordsText: string[] = []; let generatedSectionIndexes: number[] = []; try { const gen = await WordsGenerator.generateWords(language); generatedWords = gen.words; - generatedSectionIndexes = gen.sectionIndexes; + generatedWordsText = generatedWords.map((w) => w.text); wordsHaveNumbers = gen.hasNumbers; wordsHaveTab = gen.hasTab; wordsHaveNewline = gen.hasNewline; - ({ allRightToLeft, allLigatures } = gen); + ({ allLigatures } = gen); } catch (e) { hideLoaderBar(); if (e instanceof WordGenError || e instanceof Error) { @@ -555,7 +553,7 @@ async function init(): Promise { setWordsHaveNewline(wordsHaveNewline); if ( - generatedWords + generatedWordsText .join() .normalize() .match( @@ -565,33 +563,19 @@ async function init(): Promise { TestWords.words.koreanStatus = true; } - for (let i = 0; i < generatedWords.length; i++) { - TestWords.words.push( - generatedWords[i] as string, - generatedSectionIndexes[i] as number, - ); - } + TestWords.words.push(generatedWords); if (Config.keymapMode === "next" && Config.mode !== "zen") { highlight( - Arrays.nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...TestWords.words.getCurrent()], - 0, - ) as string, + Strings.splitIntoCharacters( + TestWords.words.getCurrentText(), + )[0] as string, ); } - Funbox.toggleScript(TestWords.words.getCurrent()); + Funbox.toggleScript(TestWords.words.getCurrentText()); TestUI.setLigatures(allLigatures ?? language.ligatures ?? false); - const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; - TestState.setIsLanguageRightToLeft(isLanguageRTL); - TestState.setIsDirectionReversed( - isFunboxActiveWithProperty("reverseDirection"), - ); - - console.debug("Test initialized with words", generatedWords); + console.debug("Test initialized with words", generatedWordsText); console.debug( "Test initialized with section indexes", generatedSectionIndexes, @@ -664,13 +648,20 @@ export async function addWord(): Promise { let wordCount = 0; for (let i = 0; i < section.words.length; i++) { - const word = section.words[i] as string; + const wordText = section.words[i] as string; if (wordCount >= Config.words && Config.mode === "words") { break; } wordCount++; - TestWords.words.push(word, i); - TestUI.addWord(word); + let direction = Strings.getWordDirection( + wordText, + TestState.isLanguageRightToLeft ? "rtl" : "ltr", + ); + if (TestState.isDirectionReversed) { + direction = Strings.reverseDirection(direction); + } + TestWords.words.push({ text: wordText, direction, sectionIndex: i }); + TestUI.addWord(wordText); } } } @@ -679,12 +670,12 @@ export async function addWord(): Promise { const randomWord = await WordsGenerator.getNextWord( TestWords.words.length, bound, - TestWords.words.get(TestWords.words.length - 1), - TestWords.words.get(TestWords.words.length - 2), + TestWords.words.getText(TestWords.words.length - 1), + TestWords.words.getText(TestWords.words.length - 2), ); - TestWords.words.push(randomWord.word, randomWord.sectionIndex); - TestUI.addWord(randomWord.word); + TestWords.words.push(randomWord); + TestUI.addWord(randomWord.text); } catch (e) { timerEvent.dispatch({ key: "fail", value: "word generation error" }); showErrorNotification( @@ -1134,7 +1125,7 @@ export async function finish(difficultyFailed = false): Promise { const lastWordInputLength = history[wordIndex]?.length ?? 0; - if (lastWordInputLength < TestWords.words.get(wordIndex).length) { + if (lastWordInputLength < TestWords.words.getText(wordIndex).length) { historyLength--; } @@ -1462,12 +1453,9 @@ configEvent.subscribe(({ key, newValue, nosave }) => { if (key === "keymapMode" && newValue === "next" && Config.mode !== "zen") { setTimeout(() => { highlight( - Arrays.nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...TestWords.words.getCurrent()], - 0, - ) as string, + Strings.splitIntoCharacters( + TestWords.words.getCurrentText(), + )[0] as string, ); }, 0); } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index f3f4fde3abe0..c8535a33f794 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -64,10 +64,9 @@ export function getStats(): unknown { accuracy: TestInput.accuracy, keypressTimings: TestInput.keypressTimings, keyOverlap: TestInput.keyOverlap, - wordsHistory: TestWords.words.list.slice( - 0, - TestInput.input.getHistory().length, - ), + wordsHistory: TestWords.words + .getText() + .slice(0, TestInput.input.getHistory().length), inputHistory: TestInput.input.getHistory(), }; @@ -268,14 +267,14 @@ function getTargetWords(): string[] { let targetWords = [ ...(Config.mode === "zen" ? TestInput.input.getHistory() - : TestWords.words.list), + : TestWords.words.getText()), ]; if (TestState.isActive) { targetWords.push( Config.mode === "zen" ? TestInput.input.current - : TestWords.words.getCurrent(), + : TestWords.words.getCurrentText(), ); } diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 26cf66b694e3..396a66e41f0c 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -27,6 +27,7 @@ import * as SoundController from "../controllers/sound-controller"; import { clearLowFpsMode, setLowFpsMode } from "../anim"; import { createTimer } from "animejs"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; +import { splitIntoCharacters } from "../utils/strings"; let lastLoop = 0; const newTimer = createTimer({ @@ -142,7 +143,9 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words.getCurrent().charAt(TestInput.input.current.length), + splitIntoCharacters(TestWords.words.getCurrentText())[ + TestInput.input.current.length + ] as string, ); }, 1); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 22409e06ce29..b3043cced9e2 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -228,7 +228,7 @@ async function joinOverlappingHints( hintElements: HTMLCollection, ): Promise { let wordDirection = Strings.getWordDirection( - TestWords.words.getCurrent(), + TestWords.words.getCurrentText(), TestState.isLanguageRightToLeft ? "rtl" : "ltr", ); if (TestState.isDirectionReversed) { @@ -504,7 +504,7 @@ function showWords(): void { } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { - wordsHTML += buildWordHTML(TestWords.words.get(i), i); + wordsHTML += buildWordHTML(TestWords.words.getText(i), i); } wordsEl.setHtml(wordsHTML); } @@ -741,7 +741,7 @@ export async function updateWordLetters({ `test-ui.updateWordLetters.${wordIndex}`, async () => { pendingWordData.delete(wordIndex); - const currentWord = TestWords.words.get(wordIndex); + const currentWord = TestWords.words.getText(wordIndex); if (!currentWord && Config.mode !== "zen") return; let ret = ""; const wordAtIndex = getWordElement(wordIndex); @@ -1328,7 +1328,7 @@ async function loadWordsHistory(): Promise { for (let i = 0; i < inputHistoryLength + 2; i++) { const input = TestInput.input.getHistory(i); const corrected = TestInput.corrected.getHistory(i); - const word = TestWords.words.get(i) ?? ""; + const word = TestWords.words.getText(i); const koreanRegex = /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/; const containsKorean = @@ -1753,7 +1753,9 @@ function afterAnyTestInput( if (Config.keymapMode === "next") { highlight( - TestWords.words.getCurrent().charAt(TestInput.input.current.length), + Strings.splitIntoCharacters(TestWords.words.getCurrentText())[ + TestInput.input.current.length + ] as string, ); } @@ -1957,7 +1959,7 @@ qs(".pageTest #copyWordsListButton")?.on("click", async () => { words = TestInput.input.getHistory().join(" "); } else { words = TestWords.words - .get() + .getText() .slice(0, TestInput.input.getHistory().length) .join(" "); } diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 4256c6417d96..4c38f2fa5f9d 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -1,9 +1,15 @@ import { QuoteWithTextSplit } from "../controllers/quotes-controller"; import * as TestState from "./test-state"; +import type { Direction } from "../utils/strings"; + +export type Word = { + text: string; + direction: Direction; + sectionIndex: number; +}; class Words { - public list: string[]; - public sectionIndexList: number[]; + public list: Word[]; public length: number; public haveNumbers: boolean; public haveNewlines: boolean; @@ -12,7 +18,6 @@ class Words { constructor() { this.list = []; - this.sectionIndexList = []; this.length = 0; this.haveNumbers = false; this.haveNewlines = false; @@ -20,49 +25,60 @@ class Words { this.koreanStatus = false; } - get(i?: undefined, raw?: boolean): string[]; - get(i: number, raw?: boolean): string; - get(i?: number, raw = false): string | string[] | undefined { - if (i === undefined) { - return this.list; - } else { - if (raw) { - return this.list[i]?.replace(/[.?!":\-,]/g, "")?.toLowerCase(); - } else { - return this.list[i]; - } - } + get(i?: undefined): Word[]; + get(i: number): Word | undefined; + get(i?: number): Word[] | Word | undefined { + if (i === undefined) return this.list; + else return this.list[i]; + } + + getText(i?: undefined): string[]; + getText(i: number): string; + getText(i?: number): string[] | string { + if (i === undefined) return this.list.map((w) => w.text); + else return this.list[i]?.text ?? ""; + } + + getCurrent(): Word | undefined { + return this.list[TestState.activeWordIndex]; } - getCurrent(): string { - return this.list[TestState.activeWordIndex] ?? ""; + getCurrentText(): string { + return this.list[TestState.activeWordIndex]?.text ?? ""; } - getLast(): string { - return this.list[this.list.length - 1] as string; + + getLast(): Word | undefined { + return this.list[this.length - 1]; } - push(word: string, sectionIndex: number): void { - this.list.push(word); - this.sectionIndexList.push(sectionIndex); - this.length = this.list.length; + + push(words: Word[] | Word): void { + if (Array.isArray(words)) { + this.list.push(...words); + this.length += words.length; + } else { + this.list.push(words); + this.length++; + } } reset(): void { this.list = []; - this.sectionIndexList = []; - this.length = this.list.length; + this.length = 0; this.haveNumbers = false; this.haveNewlines = false; this.haveTabs = false; this.koreanStatus = false; } + clean(): void { - for (const s of this.list) { - if (/ +/.test(s)) { - const id = this.list.indexOf(s); - const tempList = s.split(" "); - this.list.splice(id, 1); - for (let i = 0; i < tempList.length; i++) { - this.list.splice(id + i, 0, tempList[i] as string); - } + for (let i = 0; i < this.length; i++) { + const word = this.get(i); + if (!word) continue; + if (/ +/.test(word.text)) { + const tempList = word.text + .split(" ") + .map((text) => ({ ...word, text })); + this.list.splice(i, 1, ...tempList); + this.length += tempList.length - 1; } } } diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index e83d8754ebda..3534b904088d 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -106,10 +106,7 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { - return ( - (TestWords.words.sectionIndexList[TestState.activeWordIndex] as number) - - 1 - ); + return (TestWords.words.getCurrent()?.sectionIndex as number) - 1; } else { return TestInput.input.getHistory().length; } diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index 1a42a1f84d26..239776503f27 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -22,6 +22,7 @@ import { getActiveFunboxes, getActiveFunboxesWithFunction, isFunboxActiveWithFunction, + isFunboxActiveWithProperty, } from "./funbox/list"; import { WordGenError } from "../utils/word-gen-error"; @@ -597,8 +598,7 @@ let currentLanguage: LanguageObject | null = null; let isCurrentlyUsingFunboxSection = false; type GenerateWordsReturn = { - words: string[]; - sectionIndexes: number[]; + words: TestWords.Word[]; hasTab: boolean; hasNewline: boolean; hasNumbers: boolean; @@ -622,7 +622,6 @@ export async function generateWords( currentLanguage = language; const ret: GenerateWordsReturn = { words: [], - sectionIndexes: [], hasTab: false, hasNewline: false, hasNumbers: false, @@ -676,6 +675,14 @@ export async function generateWords( console.debug("Wordset", currentWordset); + // set direction varaibles here in order to use them in getNextWord() + // and before returning because of limit === 0 (for zen mode) + const isLanguageRTL = ret.allRightToLeft ?? language.rightToLeft ?? false; + TestState.setIsLanguageRightToLeft(isLanguageRTL); + TestState.setIsDirectionReversed( + isFunboxActiveWithProperty("reverseDirection"), + ); + if (limit === 0) { return ret; } @@ -686,22 +693,22 @@ export async function generateWords( const nextWord = await getNextWord( i, limit, - Arrays.nthElementFromArray(ret.words, -1) ?? "", - Arrays.nthElementFromArray(ret.words, -2) ?? "", + Arrays.nthElementFromArray(ret.words, -1)?.text ?? "", + Arrays.nthElementFromArray(ret.words, -2)?.text ?? "", ); - ret.words.push(nextWord.word); - ret.sectionIndexes.push(nextWord.sectionIndex); + ret.words.push(nextWord); + const generatedWordsLength = ret.words.length; if (customAndUsingPipeDelimiter) { //generate a given number of sections, make sure to not cut a section off const sectionFinishedAndOverLimit = currentSection.length === 0 && sectionIndex >= limit; //make sure we dont go over a hard limit, in cases where the sections are very large - const upperWordLimit = ret.words.length >= 100; - if (sectionFinishedAndOverLimit || upperWordLimit) { + const upperWordLimitReached = generatedWordsLength >= 100; + if (sectionFinishedAndOverLimit || upperWordLimitReached) { stop = true; } - } else if (ret.words.length >= limit) { + } else if (generatedWordsLength >= limit) { stop = true; } i++; @@ -725,12 +732,7 @@ export let sectionIndex = 0; export let currentSection: string[] = []; let sectionHistory: string[] = []; -let previousGetNextWordReturns: GetNextWordReturn[] = []; - -type GetNextWordReturn = { - word: string; - sectionIndex: number; -}; +let previousGetNextWordReturns: TestWords.Word[] = []; //generate next word export async function getNextWord( @@ -738,7 +740,7 @@ export async function getNextWord( wordsBound: number, previousWord: string, previousWord2: string | undefined, -): Promise { +): Promise { console.debug("Getting next word", { isRepeated: TestState.isRepeated, currentWordset, @@ -959,9 +961,17 @@ export async function getNextWord( console.debug("Word:", randomWord); + let direction = Strings.getWordDirection( + randomWord, + TestState.isLanguageRightToLeft ? "rtl" : "ltr", + ); + if (TestState.isDirectionReversed) { + direction = Strings.reverseDirection(direction); + } const ret = { - word: randomWord, - sectionIndex: sectionIndex, + text: randomWord, + direction, + sectionIndex, }; previousGetNextWordReturns.push(ret); From 08637d0272b878662530a977ee8cd38ac365d94c Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:38:58 +0300 Subject: [PATCH 6/9] add word direction class to the built html --- frontend/src/ts/test/test-logic.ts | 4 ++-- frontend/src/ts/test/test-ui.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 588aec4fe029..c7fba4557dd3 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -661,7 +661,7 @@ export async function addWord(): Promise { direction = Strings.reverseDirection(direction); } TestWords.words.push({ text: wordText, direction, sectionIndex: i }); - TestUI.addWord(wordText); + TestUI.addWord({ text: wordText, direction }); } } } @@ -675,7 +675,7 @@ export async function addWord(): Promise { ); TestWords.words.push(randomWord); - TestUI.addWord(randomWord.text); + TestUI.addWord(randomWord); } catch (e) { timerEvent.dispatch({ key: "fail", value: "word generation error" }); showErrorNotification( diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index b3043cced9e2..32219d6b8f39 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -376,12 +376,16 @@ async function updateHintsPosition(): Promise { } } -function buildWordHTML(word: string, wordIndex: number): string { +type RequireOnly = Required> & + Partial>; +type WordTextWithDirection = RequireOnly; + +function buildWordHTML(word: WordTextWithDirection, wordIndex: number): string { let newlineafter = false; - let retval = `
`; + let retval = `
`; const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); - const chars = Strings.splitIntoCharacters(word); + const chars = Strings.splitIntoCharacters(word.text); for (const char of chars) { if (funbox) { retval += funbox.functions.getWordHtml(char, true); @@ -504,7 +508,9 @@ function showWords(): void { } else { let wordsHTML = ""; for (let i = 0; i < TestWords.words.length; i++) { - wordsHTML += buildWordHTML(TestWords.words.getText(i), i); + const word = TestWords.words.get(i); + if (!word) continue; + wordsHTML += buildWordHTML(word, i); } wordsEl.setHtml(wordsHTML); } @@ -688,7 +694,7 @@ function updateWordsMargin(): void { } export function addWord( - word: string, + word: WordTextWithDirection, wordIndex = TestWords.words.length - 1, ): void { // if the current active word is the last word, we need to NOT use raf From 6e66012dc4e651bb1756214d26a6d0446eceb1df Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:41:08 +0300 Subject: [PATCH 7/9] set css direction style on .word with newly added rtl/ltr classes --- frontend/src/styles/test.scss | 18 ++++++++++-------- frontend/static/funbox/backwards.css | 4 ---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 317e16f7b67e..d9c20230d640 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -319,10 +319,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - - .wordRtl { - unicode-bidi: bidi-override; - } } &.withLigatures { .word { @@ -640,6 +636,16 @@ } } + &.ltr { + direction: ltr; + unicode-bidi: bidi-override; + } + + &.rtl { + direction: rtl; + unicode-bidi: bidi-override; + } + &.nocursor { cursor: none; } @@ -875,10 +881,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - - .wordRtl { - unicode-bidi: bidi-override; - } } &.withLigatures { .word { diff --git a/frontend/static/funbox/backwards.css b/frontend/static/funbox/backwards.css index 10d73f96a7e9..1295fa642a96 100644 --- a/frontend/static/funbox/backwards.css +++ b/frontend/static/funbox/backwards.css @@ -5,7 +5,3 @@ #words.rightToLeftTest { direction: ltr; } - -#words.withLigatures .word { - unicode-bidi: bidi-override; -} From dbfdb216d5adf87ea2855b16de5a150888569b02 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:41:21 +0300 Subject: [PATCH 8/9] use word.direction instead of calculating it mid test --- frontend/src/ts/elements/caret.ts | 80 +++++++++++++----------------- frontend/src/ts/test/caret.ts | 37 +++++++++++--- frontend/src/ts/test/pace-caret.ts | 18 +++++-- frontend/src/ts/test/test-logic.ts | 2 +- frontend/src/ts/test/test-ui.ts | 12 ++--- 5 files changed, 84 insertions(+), 65 deletions(-) diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 00cb2aff9116..9eaa0ae00a5c 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -2,7 +2,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import * as TestWords from "../test/test-words"; import { getTotalInlineMargin } from "../utils/misc"; -import { splitIntoCharacters, getWordDirection, reverseDirection } from "../utils/strings"; +import { splitIntoCharacters, type Direction } from "../utils/strings"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils, qsr } from "../utils/dom"; @@ -274,8 +274,8 @@ export class Caret { public goTo(options: { wordIndex: number; letterIndex: number; - isLanguageRightToLeft: boolean; - isDirectionReversed: boolean; + testDirection: Direction; + zenWordDirection?: Direction; animate?: boolean; animationOptions?: { duration?: number; @@ -284,11 +284,11 @@ export class Caret { }): void { if (this.style === "off") return; requestDebouncedAnimationFrame(`caret.${this.id}.goTo`, () => { - const word = wordsCache.qs( + const wordEl = wordsCache.qs( `.word[data-wordindex="${options.wordIndex}"]`, ); - const wordText = TestWords.words.getText(options.wordIndex) ?? ""; - const wordLength = splitIntoCharacters(wordText).length; + const word = TestWords.words.get(options.wordIndex); + const wordLength = splitIntoCharacters(word?.text ?? "").length; // caret can be either on the left side of the target letter or the right // we stick to the left side unless we are on the last letter or beyond @@ -298,6 +298,8 @@ export class Caret { // anything beyond just goes to the edge of the word let side: "beforeLetter" | "afterLetter" = "beforeLetter"; if (Config.mode === "zen") { + // when letterIndex === 0 in zen there is an invisible "_" letter + // and caret.side should be "beforeLetter" in that case if (options.letterIndex > 0) { side = "afterLetter"; options.letterIndex -= 1; @@ -318,15 +320,17 @@ export class Caret { options.letterIndex = 0; } - if (word === null) return; + if (wordEl === null) return; + + const wordDirection = + word?.direction ?? options.zenWordDirection ?? options.testDirection; const { left, top, width } = this.getTargetPositionAndWidth({ - word, + wordEl, letterIndex: options.letterIndex, - wordText, side, - isLanguageRightToLeft: options.isLanguageRightToLeft, - isDirectionReversed: options.isDirectionReversed, + testDirection: options.testDirection, + wordDirection, }); // animation uses inline styles, so its fine to read inline here instead @@ -387,14 +391,13 @@ export class Caret { } private getTargetPositionAndWidth(options: { - word: ElementWithUtils; + wordEl: ElementWithUtils; letterIndex: number; - wordText: string; side: "beforeLetter" | "afterLetter"; - isLanguageRightToLeft: boolean; - isDirectionReversed: boolean; + testDirection: Direction; + wordDirection: Direction; }): { left: number; top: number; width: number } { - const letters = options.word?.qsa("letter"); + const letters = options.wordEl?.qsa("letter"); let letter; if (!letters?.length || !(letter = letters[options.letterIndex])) { // maybe we should return null here instead of throwing @@ -417,19 +420,6 @@ export class Caret { this.element.removeClass("debug"); } - // in zen, custom or polyglot mode we need to check per-letter - const checkRtlByLetter = - Config.mode === "zen" || - Config.mode === "custom" || - Config.funbox.includes("polyglot"); - let wordDirection = getWordDirection( - checkRtlByLetter ? (letter.native.textContent ?? "") : options.wordText, - options.isLanguageRightToLeft ? "rtl" : "ltr", - ); - if (options.isDirectionReversed) { - wordDirection = reverseDirection(wordDirection); - } - //if the letter is not visible, use the closest visible letter const isLetterVisible = letter.getOffsetWidth() > 0; if (!isLetterVisible) { @@ -447,7 +437,7 @@ export class Caret { } } - const spaceWidth = getTotalInlineMargin(options.word.native); + const spaceWidth = getTotalInlineMargin(options.wordEl.native); let width = spaceWidth; if (this.isFullWidth() && options.side === "beforeLetter") { width = letter.getOffsetWidth(); @@ -456,12 +446,12 @@ export class Caret { let left = 0; let top = 0; - const tapeOffset = - wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100); + let tapeMarginRatio = Config.tapeMargin / 100; + if (options.testDirection === "rtl") tapeMarginRatio = 1 - tapeMarginRatio; + const tapeOffset = wordsWrapperCache.getOffsetWidth() * tapeMarginRatio; // yes, this is all super verbose, but its easier to maintain and understand - if (wordDirection === "rtl") { - if (!checkRtlByLetter) options.word.addClass("wordRtl"); + if (options.wordDirection === "rtl") { let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { @@ -475,30 +465,30 @@ export class Caret { left += letter.getOffsetWidth(); } left += letter.getOffsetLeft(); - left += options.word.getOffsetLeft(); + left += options.wordEl.getOffsetLeft(); left += afterLetterCorrection; } else if (Config.tapeMode === "word") { if (!this.isFullWidth()) { left += letter.getOffsetWidth(); } - left += options.word.getOffsetWidth() * -1; + left += options.wordEl.getOffsetWidth() * -1; left += letter.getOffsetLeft(); left += afterLetterCorrection; if (this.isMainCaret && lockedMainCaretInTape) { - left += wordsWrapperCache.getOffsetWidth() - tapeOffset; + left += tapeOffset; } else { - left += options.word.getOffsetLeft(); - left += options.word.getOffsetWidth(); + left += options.wordEl.getOffsetLeft(); + left += options.wordEl.getOffsetWidth(); } } else if (Config.tapeMode === "letter") { if (this.isFullWidth()) { left += width * -1; } if (this.isMainCaret && lockedMainCaretInTape) { - left += wordsWrapperCache.getOffsetWidth() - tapeOffset; + left += tapeOffset; } else { left += letter.getOffsetLeft(); - left += options.word.getOffsetLeft(); + left += options.wordEl.getOffsetLeft(); left += afterLetterCorrection; left += width; } @@ -510,7 +500,7 @@ export class Caret { } if (Config.tapeMode === "off") { left += letter.getOffsetLeft(); - left += options.word.getOffsetLeft(); + left += options.wordEl.getOffsetLeft(); left += afterLetterCorrection; } else if (Config.tapeMode === "word") { left += letter.getOffsetLeft(); @@ -518,14 +508,14 @@ export class Caret { if (this.isMainCaret && lockedMainCaretInTape) { left += tapeOffset; } else { - left += options.word.getOffsetLeft(); + left += options.wordEl.getOffsetLeft(); } } else if (Config.tapeMode === "letter") { if (this.isMainCaret && lockedMainCaretInTape) { left += tapeOffset; } else { left += letter.getOffsetLeft(); - left += options.word.getOffsetLeft(); + left += options.wordEl.getOffsetLeft(); left += afterLetterCorrection; } } @@ -533,7 +523,7 @@ export class Caret { //top position top += letter.getOffsetTop(); - top += options.word.getOffsetTop(); + top += options.wordEl.getOffsetTop(); if (this.style === "underline") { // if style is underline, add the height of the letter to the top diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index bbb380838266..cc016c553bf6 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -5,6 +5,14 @@ import { configEvent } from "../events/config"; import { Caret } from "../elements/caret"; import * as CompositionState from "../legacy-states/composition"; import { qsr } from "../utils/dom"; +import { + getWordDirection, + reverseDirection, + splitIntoCharacters, + type Direction, +} from "../utils/strings"; + +let testDirection: Direction; export function stopAnimation(): void { caret.stopBlinking(); @@ -21,22 +29,39 @@ export function hide(): void { export function resetPosition(): void { caret.stopAllAnimations(); caret.clearMargins(); + caret.goTo({ wordIndex: 0, letterIndex: 0, - isLanguageRightToLeft: TestState.isLanguageRightToLeft, - isDirectionReversed: TestState.isDirectionReversed, + testDirection, + zenWordDirection: Config.mode === "zen" ? testDirection : undefined, animate: false, }); } +export function init(): void { + const langDirection = TestState.isLanguageRightToLeft ? "rtl" : "ltr"; + testDirection = TestState.isDirectionReversed + ? reverseDirection(langDirection) + : langDirection; +} + export function updatePosition(noAnim = false): void { + const inputWord = splitIntoCharacters( + TestInput.input.current + CompositionState.getData(), + ); + const inputWordLength = inputWord.length; + + const zenWordDirection = + Config.mode === "zen" + ? getWordDirection(inputWord[inputWordLength - 1], testDirection) + : undefined; + caret.goTo({ wordIndex: TestState.activeWordIndex, - letterIndex: - TestInput.input.current.length + CompositionState.getData().length, - isLanguageRightToLeft: TestState.isLanguageRightToLeft, - isDirectionReversed: TestState.isDirectionReversed, + letterIndex: inputWordLength, + testDirection, + zenWordDirection, animate: Config.smoothCaret !== "off" && !noAnim, }); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index f2ccdeda22ac..7915b6233f3d 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -7,6 +7,7 @@ import { configEvent } from "../events/config"; import { getActiveFunboxes } from "./funbox/list"; import { Caret } from "../elements/caret"; import { qsr } from "../utils/dom"; +import { reverseDirection, type Direction } from "../utils/strings"; type Settings = { wpm: number; @@ -27,6 +28,8 @@ export const caret = new Caret(qsr("#paceCaret"), Config.paceCaretStyle); let lastTestWpm = 0; +let testDirection: Direction; + export function setLastTestWpm(wpm: number): void { if ( !TestState.isPaceRepeat || @@ -36,7 +39,7 @@ export function setLastTestWpm(wpm: number): void { } } -export function resetCaretPosition(): void { +export function resetPosition(): void { if (Config.paceCaret === "off" && !TestState.isPaceRepeat) return; if (Config.mode === "zen") return; @@ -47,8 +50,8 @@ export function resetCaretPosition(): void { caret.goTo({ wordIndex: 0, letterIndex: 0, - isLanguageRightToLeft: TestState.isLanguageRightToLeft, - isDirectionReversed: TestState.isDirectionReversed, + testDirection, + zenWordDirection: undefined, animate: false, }); } @@ -127,6 +130,11 @@ export async function init(): Promise { wordsStatus: {}, timeout: null, }; + + const langDirection = TestState.isLanguageRightToLeft ? "rtl" : "ltr"; + testDirection = TestState.isDirectionReversed + ? reverseDirection(langDirection) + : langDirection; } export async function update(expectedStepEnd: number): Promise { @@ -153,8 +161,8 @@ export async function update(expectedStepEnd: number): Promise { caret.goTo({ wordIndex: currentSettings.currentWordIndex, letterIndex: currentSettings.currentLetterIndex, - isLanguageRightToLeft: TestState.isLanguageRightToLeft, - isDirectionReversed: TestState.isDirectionReversed, + testDirection, + zenWordDirection: undefined, animate: true, animationOptions: { duration, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index c7fba4557dd3..dc78e6d00dcd 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -301,7 +301,6 @@ export function restart(options = {} as RestartOptions): void { Replay.stopReplayRecording(); Replay.pauseReplay(); TestState.setBailedOut(false); - Caret.resetPosition(); PaceCaret.reset(); clearQuoteStats(); CompositionState.setComposing(false); @@ -356,6 +355,7 @@ export function restart(options = {} as RestartOptions): void { return; } + Caret.init(); await PaceCaret.init(); for (const fb of getActiveFunboxesWithFunction("restart")) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 32219d6b8f39..ff0bb39384c5 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -227,13 +227,8 @@ async function joinOverlappingHints( activeWordLetters: ElementsWithUtils, hintElements: HTMLCollection, ): Promise { - let wordDirection = Strings.getWordDirection( - TestWords.words.getCurrentText(), - TestState.isLanguageRightToLeft ? "rtl" : "ltr", - ); - if (TestState.isDirectionReversed) { - wordDirection = Strings.reverseDirection(wordDirection); - } + let wordDirection = TestWords.words.getCurrent()?.direction; + if (!wordDirection) return; let previousBlocksAdjacent = false; let currentHintBlock = 0; @@ -519,7 +514,8 @@ function showWords(): void { initial: true, }); updateWordWrapperClasses(); - PaceCaret.resetCaretPosition(); + Caret.resetPosition(); + PaceCaret.resetPosition(); } export function appendEmptyWordElement( From 98095532b4c2bf19e8e9e37e4e216bcf792a3e75 Mon Sep 17 00:00:00 2001 From: Nad Alaba <37968805+nadalaba@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:41:21 +0300 Subject: [PATCH 9/9] refactor loadWordsHistory --- frontend/src/ts/test/test-ui.ts | 56 ++++++++++++++++----------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ff0bb39384c5..418bb16f4efb 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1257,32 +1257,46 @@ export function setLigatures(isEnabled: boolean): void { } function buildWordLettersHTML( - charCount: number, input: string, corrected: string, - inputCharacters: string[], - wordCharacters: string[], - correctedCharacters: string[], + word: string, containsKorean: boolean, ): string { + const inputCharacters = Strings.splitIntoCharacters(input); + const correctedCharacters = Strings.splitIntoCharacters(corrected); + const wordCharacters = Strings.splitIntoCharacters(word); + + const koreanCorrectedCharacters = Strings.splitIntoCharacters( + Hangul.assemble(corrected.split("")), + ); + + let loopCharCount; + if (Config.mode === "zen" || inputCharacters.length > wordCharacters.length) { + //input is longer - extra characters possible (loop over input) + loopCharCount = inputCharacters.length; + } else { + //input is shorter or equal (loop over word list) + loopCharCount = wordCharacters.length; + } + let out = ""; - for (let c = 0; c < charCount; c++) { + for (let c = 0; c < loopCharCount; c++) { let correctedChar; try { correctedChar = !containsKorean ? correctedCharacters[c] - : Hangul.assemble(corrected.split(""))[c]; + : koreanCorrectedCharacters[c]; } catch (e) { correctedChar = undefined; } let extraCorrected = ""; - const historyWord: string = !containsKorean - ? corrected - : Hangul.assemble(corrected.split("")); + const historyChars = !containsKorean + ? correctedCharacters + : koreanCorrectedCharacters; if ( - c + 1 === charCount && - historyWord !== undefined && - historyWord.length > input.length + c + 1 === loopCharCount && + historyChars !== undefined && + historyChars.length > input.length ) { extraCorrected = "extraCorrected"; } @@ -1378,28 +1392,12 @@ async function loadWordsHistory(): Promise { wordEl.setAttribute("input", input.replace(/ /g, "_")); } - const inputCharacters = Strings.splitIntoCharacters(input); - const wordCharacters = Strings.splitIntoCharacters(word); - const correctedCharacters = Strings.splitIntoCharacters(corrected ?? ""); - - let loop; - if (Config.mode === "zen" || input.length > word.length) { - //input is longer - extra characters possible (loop over input) - loop = inputCharacters.length; - } else { - //input is shorter or equal (loop over word list) - loop = wordCharacters.length; - } - if (corrected === undefined) throw new Error("empty corrected word"); wordEl.innerHTML = buildWordLettersHTML( - loop, input, corrected, - inputCharacters, - wordCharacters, - correctedCharacters, + word, containsKorean, ); } catch (e) {