diff --git a/frontend/__tests__/test/events/stats.spec.ts b/frontend/__tests__/test/events/stats.spec.ts index 862fd8a8db06..26166b31a9d8 100644 --- a/frontend/__tests__/test/events/stats.spec.ts +++ b/frontend/__tests__/test/events/stats.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"; vi.mock("../../../src/ts/test/test-stats", () => ({ start: 1000, @@ -26,16 +26,8 @@ vi.mock("../../../src/ts/test/test-words", () => { get(): Word[] { return [...list]; }, - push(word: string) { - let commit: CommitChar = ""; - if (word.endsWith(" ")) { - commit = " "; - word = word.slice(0, -1); - } else if (word.endsWith("\n")) { - commit = "\n"; - word = word.slice(0, -1); - } - list.push({ text: word, textWithCommit: word + commit, commit }); + push(word: { text: string; commit: CommitChar }) { + list.push({ ...word, textWithCommit: word.text + word.commit }); }, reset() { list.length = 0; @@ -91,9 +83,15 @@ import type { import { Config } from "../../../src/ts/config/store"; import { Keycode } from "../../../src/ts/constants/keys"; import * as TestState from "../../../src/ts/test/test-state"; -import { words as TestWords } from "../../../src/ts/test/test-words"; +import { + words as TestWords, + type CommitChar, +} from "../../../src/ts/test/test-words"; import { isFunboxActiveWithProperty } from "../../../src/ts/test/funbox/list"; +// tell TypeScript this is a mock +const mockedTestWordsPush = TestWords.push as Mock; + // mirror the generator: each word carries a trailing space separator unless it // already ends with a newline, the nospace funbox is active, or it's the last // word (the final separator is stripped once all words are generated) @@ -101,9 +99,10 @@ function pushWords(...words: string[]): void { const nospace = isFunboxActiveWithProperty("nospace"); words.forEach((word, i) => { const isLast = i === words.length - 1; - const withSeparator = - isLast || nospace || word.endsWith("\n") ? word : `${word} `; - TestWords.push(withSeparator, i); + const match = /(.*?)( |\n|)$/.exec(word) as RegExpExecArray; + let commit = match[2] as CommitChar; + if (commit === "" && !isLast && !nospace) commit = " "; + mockedTestWordsPush({ text: match[1] as string, commit }); }); } diff --git a/frontend/__tests__/test/test-words.spec.ts b/frontend/__tests__/test/test-words.spec.ts index 8c9a759f69d4..fa8ef069e90f 100644 --- a/frontend/__tests__/test/test-words.spec.ts +++ b/frontend/__tests__/test/test-words.spec.ts @@ -11,47 +11,48 @@ describe("test-words", () => { words.reset(); }); - describe("push", () => { - // separators are added by the generator as a trailing space/newline; push - // splits that into the commit char while keeping the bare word as text - it("splits the trailing separator into the commit char", () => { - words.push("the ", 0); - words.push("cat ", 0); - words.push("sat", 0); - expect(words.get().map((w) => w.text)).toEqual(["the", "cat", "sat"]); - expect(words.get().map((w) => w.commit)).toEqual([" ", " ", ""]); - expect(words.get().map((w) => w.textWithCommit)).toEqual([ - "the ", - "cat ", - "sat", - ]); - }); - - it("tracks length and section indexes", () => { - words.push("a ", 3); - words.push("b", 5); - expect(words.length).toBe(2); - expect(words.get().map((w) => w.sectionIndex)).toEqual([3, 5]); - }); - }); - describe("removeCommitCharacterFromLastWord", () => { it("strips a trailing space from the last word", () => { - words.push("the ", 0); - words.push("end ", 0); + words.push({ + text: "the", + commit: " ", + direction: "ltr", + sectionIndex: 0, + }); + words.push({ + text: "end", + commit: " ", + direction: "ltr", + sectionIndex: 0, + }); words.removeCommitCharacterFromLastWord(); expect(words.get().map((w) => w.textWithCommit)).toEqual(["the ", "end"]); }); it("strips a trailing newline from the last word", () => { - words.push("line\n", 0); + words.push({ + text: "line", + commit: "\n", + direction: "ltr", + sectionIndex: 0, + }); words.removeCommitCharacterFromLastWord(); expect(words.get().map((w) => w.textWithCommit)).toEqual(["line"]); }); it("leaves a bare last word unchanged", () => { - words.push("the ", 0); - words.push("end", 0); + words.push({ + text: "the", + commit: " ", + direction: "ltr", + sectionIndex: 0, + }); + words.push({ + text: "end", + commit: "", + direction: "ltr", + sectionIndex: 0, + }); words.removeCommitCharacterFromLastWord(); expect(words.get().map((w) => w.textWithCommit)).toEqual(["the ", "end"]); }); diff --git a/frontend/__tests__/test/words-generator.spec.ts b/frontend/__tests__/test/words-generator.spec.ts new file mode 100644 index 000000000000..5ded720ba62c --- /dev/null +++ b/frontend/__tests__/test/words-generator.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, type Mock } from "vitest"; + +vi.mock("../../src/ts/test/funbox/active", () => ({ + isFunboxActiveWithProperty: vi.fn(), +})); + +import { isFunboxActiveWithProperty } from "../../src/ts/test/funbox/active"; +import * as WordsGenerator from "../../src/ts/test/words-generator"; + +// tell TypeScript this is a mock +const mockedIsFunboxActiveWithProperty = isFunboxActiveWithProperty as Mock; + +describe("words-generator", () => { + describe("getTextWithCommitChar", () => { + it("splits the trailing separator into the commit char", () => { + expect(WordsGenerator.getTextWithCommitChar("the ")).toEqual({ + text: "the", + commit: " ", + }); + expect(WordsGenerator.getTextWithCommitChar("cat\n")).toEqual({ + text: "cat", + commit: "\n", + }); + }); + + it("adds a space if there is no trailing spearator and 'nospace' funbox is off", () => { + mockedIsFunboxActiveWithProperty.mockReturnValue(false); + expect(WordsGenerator.getTextWithCommitChar("the ")).toEqual({ + text: "the", + commit: " ", + }); + expect(WordsGenerator.getTextWithCommitChar("cat\n")).toEqual({ + text: "cat", + commit: "\n", + }); + expect(WordsGenerator.getTextWithCommitChar("sat")).toEqual({ + text: "sat", + commit: " ", + }); + }); + + it("doesn't add a space if 'nospace' funbox is on", () => { + mockedIsFunboxActiveWithProperty.mockReturnValue(true); + expect(WordsGenerator.getTextWithCommitChar("the ")).toEqual({ + text: "the", + commit: " ", + }); + expect(WordsGenerator.getTextWithCommitChar("cat\n")).toEqual({ + text: "cat", + commit: "\n", + }); + expect(WordsGenerator.getTextWithCommitChar("sat")).toEqual({ + text: "sat", + commit: "", + }); + }); + }); +}); diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 01bcd6f441b1..6c1798c8d20e 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import * as Strings from "../../src/ts/utils/strings"; describe("string utils", () => { @@ -217,234 +217,178 @@ 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"], + + // numerals without strong typed characters should return "ltr" + ["ltr", "0123456789", "digits with no strong typed chars"], + ["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 with ltr fallback", + ], + [ + "ltr", + "123", + "rtl", + "regular digits with no strong typed chars with rtl fallback", + ], + [ + "ltr", + "١٢٣", + "ltr", + "Arabic-Indic digits with no strong typed chars with ltr fallback", + ], + [ + "ltr", + "١٢٣", + "rtl", + "Arabic-Indic digits with no strong typed chars with rtl fallback", + ], ] 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", () => { const mapGetSpy = vi.spyOn(Map.prototype, "get"); const mapSetSpy = vi.spyOn(Map.prototype, "set"); - const mapClearSpy = vi.spyOn(Map.prototype, "clear"); afterEach(() => { mapGetSpy.mockReset(); mapSetSpy.mockReset(); - mapClearSpy.mockReset(); }); 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 @@ -452,10 +396,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/styles/test.scss b/frontend/src/styles/test.scss index 00667beba472..88dae3e2ee93 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -315,10 +315,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - - .wordRtl { - unicode-bidi: bidi-override; - } } &.joiningScript { .word { @@ -635,6 +631,16 @@ } } + &.ltr { + direction: ltr; + unicode-bidi: bidi-override; + } + + &.rtl { + direction: rtl; + unicode-bidi: bidi-override; + } + &.nocursor { cursor: none; } @@ -870,10 +876,6 @@ &.rightToLeftTest { //flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages direction: rtl; - - .wordRtl { - unicode-bidi: bidi-override; - } } &.joiningScript { .word { diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index 5b603bf21950..c3bc1ce2a4c1 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -1,7 +1,7 @@ import { CaretStyle } from "@monkeytype/schemas/configs"; import { Config } from "../config/store"; import { getTotalInlineMargin } from "../utils/misc"; -import { isWordRightToLeft } 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.get(options.wordIndex)?.display ?? ""; - const wordLength = Array.from(wordText).length; + const word = TestWords.words.get(options.wordIndex); + const wordLength = splitIntoCharacters(word?.display ?? "").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"); if (letters.length === 0) { throw new Error( @@ -424,17 +427,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"); - const [isWordRTL, isFullMatch] = isWordRightToLeft( - checkRtlByLetter ? (letter.native.textContent ?? "") : options.wordText, - options.isLanguageRightToLeft, - options.isDirectionReversed, - ); - //if the letter is not visible, use the closest visible letter const isLetterVisible = letter.getOffsetWidth() > 0; if (!isLetterVisible) { @@ -452,7 +444,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(); @@ -461,12 +453,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 (isWordRTL) { - if (!checkRtlByLetter && isFullMatch) options.word.addClass("wordRtl"); + if (options.wordDirection === "rtl") { let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { @@ -480,30 +472,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; } @@ -515,7 +507,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(); @@ -523,14 +515,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; } } @@ -538,7 +530,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 187f60b998ac..d69de4d71973 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,21 +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( + getCurrentInput() + CompositionState.getData(), + ); + const inputWordLength = inputWord.length; + + const zenWordDirection = + Config.mode === "zen" + ? getWordDirection(inputWord[inputWordLength - 1], testDirection) + : undefined; + caret.goTo({ wordIndex: TestState.activeWordIndex, - letterIndex: getCurrentInput().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/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 08be80e444d5..2dad615a2dc3 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -423,9 +423,9 @@ const list: Partial> = { } setTimeout(() => { highlight( - TestWords.words - .getCurrent() - ?.text.charAt(getCurrentInput().length) ?? "", + Strings.splitIntoCharacters( + TestWords.words.getCurrent()?.text ?? "", + )[getCurrentInput().length] as string, ); }, 1); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 55348cf7888d..5678b461a245 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -18,6 +18,7 @@ import { isTestActive, setPaceCaretWpm, } from "../states/test"; +import { reverseDirection, type Direction } from "../utils/strings"; type Settings = { wpm: number; @@ -38,13 +39,15 @@ export const caret = new Caret(qsr("#paceCaret"), Config.paceCaretStyle); let lastTestWpm = 0; +let testDirection: Direction; + export function setLastTestWpm(wpm: number): void { if (!isPaceRepeat() || (isPaceRepeat() && wpm > lastTestWpm)) { lastTestWpm = wpm; } } -export function resetCaretPosition(): void { +export function resetPosition(): void { if (Config.paceCaret === "off" && !isPaceRepeat()) return; if (Config.mode === "zen") return; @@ -55,8 +58,8 @@ export function resetCaretPosition(): void { caret.goTo({ wordIndex: 0, letterIndex: 0, - isLanguageRightToLeft: TestState.isLanguageRightToLeft, - isDirectionReversed: TestState.isDirectionReversed, + testDirection, + zenWordDirection: undefined, animate: false, }); } @@ -117,6 +120,11 @@ export async function init(): Promise { timeout: null, }; setPaceCaretWpm(wpm); + + const langDirection = TestState.isLanguageRightToLeft ? "rtl" : "ltr"; + testDirection = TestState.isDirectionReversed + ? reverseDirection(langDirection) + : langDirection; } export async function update(expectedStepEnd: number): Promise { @@ -139,8 +147,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, @@ -177,54 +185,58 @@ export function reset(): void { function incrementLetterIndex(): void { if (settings === null) return; - try { - if ( - settings.currentLetterIndex >= - // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex)!.text.length - ) { - //go to the next word - settings.currentLetterIndex = -1; - settings.currentWordIndex++; - } - settings.currentLetterIndex++; - - if (!Config.blindMode) { - if (settings.correction < 0) { - while (settings.correction < 0) { - settings.currentLetterIndex--; - if (settings.currentLetterIndex <= -1) { - //go to the previous word - settings.currentLetterIndex = - // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex - 1)!.text.length; - settings.currentWordIndex--; - } - settings.correction++; - } - } else if (settings.correction > 0) { - while (settings.correction > 0) { - settings.currentLetterIndex++; - if ( - settings.currentLetterIndex >= - // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined - TestWords.words.get(settings.currentWordIndex)!.text.length + 1 - ) { - //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/test-logic.ts b/frontend/src/ts/test/test-logic.ts index cabf7eb36dc4..386aaee4d718 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -73,7 +73,6 @@ import { getActiveFunboxesWithFunction, getActiveFunboxNames, isFunboxActive, - isFunboxActiveWithProperty, } from "./funbox/list"; import { getFunbox } from "@monkeytype/funbox"; import * as CompositionState from "../legacy-states/composition"; @@ -122,7 +121,6 @@ import { calculateWpm } from "../utils/numbers"; import { isDevEnvironment } from "../utils/env"; import { EventLog } from "./events/types"; import { resetModifierState } from "../states/modifiers"; -import { nthElementFromArray } from "../utils/arrays"; let failReason = ""; @@ -175,7 +173,6 @@ export function restart(options = {} as RestartOptions): void { }; options = { ...defaultOptions, ...options }; - Strings.clearWordDirectionCache(); const animationTime = options.noAnim ? 0 : Misc.applyReducedMotion(125); @@ -288,7 +285,6 @@ export function restart(options = {} as RestartOptions): void { setTestActive(false); Replay.pauseReplay(); TestState.setBailedOut(false); - Caret.resetPosition(); PaceCaret.reset(); TestState.setKoreanStatus(false); clearQuoteStats(); @@ -344,6 +340,7 @@ export function restart(options = {} as RestartOptions): void { return; } + Caret.init(); await PaceCaret.init(); for (const fb of getActiveFunboxesWithFunction("restart")) { @@ -493,19 +490,21 @@ async function init(): Promise { currentQuote: getCurrentQuote(), }); + let wordsHaveNumbers = false; let wordsHaveTab = false; let wordsHaveNewline = false; - let allRightToLeft: boolean | undefined = undefined; + let wordsKoreanStatus = false; let allJoiningScript: boolean | undefined = undefined; - let generatedWords: string[] = []; - let generatedSectionIndexes: number[] = []; + let generatedWords: TestWords.WordMinimal[] = []; try { const gen = await WordsGenerator.generateWords(language); generatedWords = gen.words; - generatedSectionIndexes = gen.sectionIndexes; + wordsHaveNumbers = gen.hasNumbers; wordsHaveTab = gen.hasTab; wordsHaveNewline = gen.hasNewline; - ({ allRightToLeft, allJoiningScript } = gen); + wordsKoreanStatus = gen.koreanStatus; + + ({ allJoiningScript } = gen); } catch (e) { hideLoaderBar(); if (e instanceof WordGenError || e instanceof Error) { @@ -528,65 +527,31 @@ async function init(): Promise { return await init(); } - let hasNumbers = false; - - for (const word of generatedWords) { - if (/\d/g.test(word) && !hasNumbers) { - hasNumbers = true; - } - } - - setWordsHaveNumbers(hasNumbers); + TestWords.words.haveNumbers = wordsHaveNumbers; + TestWords.words.haveNewlines = wordsHaveNewline; + TestWords.words.haveTabs = wordsHaveTab; + TestWords.words.koreanStatus = wordsKoreanStatus; + setWordsHaveNumbers(wordsHaveNumbers); setWordsHaveTab(wordsHaveTab); setWordsHaveNewline(wordsHaveNewline); - if ( - generatedWords - .join() - .normalize() - .match( - /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g, - ) - ) { - TestState.setKoreanStatus(true); - } - - for (let i = 0; i < generatedWords.length; i++) { - TestWords.words.push( - generatedWords[i] as string, - generatedSectionIndexes[i] as number, - ); - } + for (const word of generatedWords) TestWords.words.push(word); if (WordsGenerator.areAllWordsGenerated()) { TestWords.words.removeCommitCharacterFromLastWord(); } if (Config.keymapMode === "next" && Config.mode !== "zen") { - highlight( - nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...(TestWords.words.getCurrent()?.text ?? "")], - 0, - ) as string, - ); + const keyToHighlight = Strings.splitIntoCharacters( + TestWords.words.getCurrent()?.textWithCommit ?? "", + )[0]; + if (keyToHighlight !== undefined) highlight(keyToHighlight); } Funbox.toggleScript(TestWords.words.getCurrent()?.text ?? ""); TestUI.setJoiningClass(allJoiningScript ?? language.joiningScript ?? false); - const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; - TestState.setIsLanguageRightToLeft(isLanguageRTL); - TestState.setIsDirectionReversed( - isFunboxActiveWithProperty("reverseDirection"), - ); - console.debug("Test initialized with words", TestWords.words.get()); - console.debug( - "Test initialized with section indexes", - generatedSectionIndexes, - ); return true; } @@ -635,16 +600,26 @@ 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++; - const newWord = TestWords.words.push( - WordsGenerator.appendCommitCharacter(word), - i, + let direction = Strings.getWordDirection( + wordText, + TestState.isLanguageRightToLeft ? "rtl" : "ltr", ); - TestUI.addWord(newWord.display); + if (TestState.isDirectionReversed) { + direction = Strings.reverseDirection(direction); + } + const textWithCommit = WordsGenerator.getTextWithCommitChar(wordText); + const newWord = TestWords.words.push({ + text: textWithCommit.text, + commit: textWithCommit.commit, + direction, + sectionIndex: i, + }); + TestUI.addWord({ text: newWord.display, direction }); } } } @@ -657,11 +632,8 @@ export async function addWord(): Promise { TestWords.words.get(TestWords.words.length - 2)?.text, ); - const newWord = TestWords.words.push( - randomWord.word, - randomWord.sectionIndex, - ); - TestUI.addWord(newWord.display); + const newWord = TestWords.words.push(randomWord); + TestUI.addWord({ text: newWord.display, direction: newWord.direction }); } catch (e) { timerEvent.dispatch({ key: "fail", value: "word generation error" }); showErrorNotification( @@ -1369,14 +1341,10 @@ configEvent.subscribe(({ key, newValue, nosave }) => { if (key === "keymapMode" && newValue === "next" && Config.mode !== "zen") { setTimeout(() => { - highlight( - nthElementFromArray( - // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...(TestWords.words.getCurrent()?.text ?? "")], - 0, - ) as string, - ); + const keyToHighlight = Strings.splitIntoCharacters( + TestWords.words.getCurrent()?.textWithCommit ?? "", + )[0]; + if (keyToHighlight !== undefined) highlight(keyToHighlight); }, 0); } if ( diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index f410da020534..a17c058e3bcc 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -33,6 +33,7 @@ import { import { getChars } from "./events/stats"; import { calculateWpm } from "../utils/numbers"; import { isTestActive, setCurrentLiveStats } from "../states/test"; +import { splitIntoCharacters } from "../utils/strings"; let timerStartMs = 0; let stopped = true; @@ -181,9 +182,9 @@ function layoutfluid(): void { if (Config.keymapMode === "next") { setTimeout(() => { highlight( - TestWords.words - .getCurrent() - ?.text.charAt(getCurrentInput().length) ?? "", + splitIntoCharacters(TestWords.words.getCurrent()?.text ?? "")[ + getCurrentInput().length + ] ?? "", ); }, 1); } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 62f2a2b6ca91..1d08a1af600e 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -227,14 +227,8 @@ async function joinOverlappingHints( activeWordLetters: ElementsWithUtils, hintElements: HTMLCollection, ): Promise { - const currentWord = TestWords.words.getCurrent(); - if (currentWord === undefined) return; - - const [isWordRightToLeft] = Strings.isWordRightToLeft( - currentWord.text, - TestState.isLanguageRightToLeft, - TestState.isDirectionReversed, - ); + let wordDirection = TestWords.words.getCurrent()?.direction; + if (!wordDirection) return; let previousBlocksAdjacent = false; let currentHintBlock = 0; @@ -271,8 +265,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; @@ -287,7 +281,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 = `${block1Letter1Pos + bothBlocksLettersWidthHalved}px`; @@ -376,12 +370,14 @@ async function updateHintsPosition(): Promise { } } -function buildWordHTML(word: string, wordIndex: number): string { +type WordTextWithDirection = Pick; + +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); @@ -506,7 +502,10 @@ function showWords(): void { for (let i = 0; i < TestWords.words.length; i++) { const word = TestWords.words.get(i); if (word === undefined) continue; // won't happen, but ts complains - wordsHTML += buildWordHTML(word.display, i); + wordsHTML += buildWordHTML( + { text: word.display, direction: word.direction }, + i, + ); } wordsEl.setHtml(wordsHTML); } @@ -515,7 +514,8 @@ function showWords(): void { initial: true, }); updateWordWrapperClasses(); - PaceCaret.resetCaretPosition(); + Caret.resetPosition(); + PaceCaret.resetPosition(); } export function appendEmptyWordElement(index: number): void { @@ -688,7 +688,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 @@ -1284,13 +1284,11 @@ function buildWordLettersHTML( let correctedChar = correctedChars[c]; let extraCorrected = ""; - const historyWord: string = !TestState.koreanStatus - ? (corrected ?? "") - : Hangul.assemble((corrected ?? "").split("")); + if ( c >= targetChars.length - 1 && c + 1 === inputChars.length && - historyWord.length > inputChars.length + correctedChars.length > inputChars.length ) { extraCorrected = "extraCorrected"; } @@ -1740,8 +1738,9 @@ function afterAnyTestInput( } if (Config.keymapMode === "next") { - const keyToHighlight = - TestWords.words.getCurrent()?.textWithCommit[getCurrentInput().length]; + const keyToHighlight = Strings.splitIntoCharacters( + TestWords.words.getCurrent()?.textWithCommit ?? "", + )[getCurrentInput().length]; if (keyToHighlight !== undefined) { highlight(keyToHighlight); } diff --git a/frontend/src/ts/test/test-words.ts b/frontend/src/ts/test/test-words.ts index 2f692627dc6c..f891f4650e3e 100644 --- a/frontend/src/ts/test/test-words.ts +++ b/frontend/src/ts/test/test-words.ts @@ -1,26 +1,50 @@ import * as TestState from "./test-state"; +import type { Direction } from "../utils/strings"; -type CommitChar = " " | "\n" | ""; +export type CommitChar = " " | "\n" | ""; type Word = { text: string; textWithCommit: string; commit: CommitChar; display: string; + direction: Direction; sectionIndex: number; }; +export type WordMinimal = Omit; + const commitCharsToDisplay: Set = new Set(["\n"]); class Words { private list: Word[]; public length: number; + public haveNumbers: boolean; + public haveNewlines: boolean; + public haveTabs: boolean; + public koreanStatus: boolean; constructor() { this.list = []; + this.haveNumbers = false; + this.haveNewlines = false; + this.haveTabs = false; + this.koreanStatus = false; this.length = 0; } + private createFullWord(minimalWord: WordMinimal): Word { + return { + ...minimalWord, + textWithCommit: minimalWord.text + minimalWord.commit, + display: + minimalWord.text + + (commitCharsToDisplay.has(minimalWord.commit) + ? minimalWord.commit + : ""), + }; + } + get(i?: undefined, raw?: boolean): Word[]; get(i: number, raw?: boolean): Word | undefined; get(i?: number, raw = false): Word | Word[] | undefined { @@ -33,38 +57,19 @@ class Words { } if (raw) { const text = word.text.replace(/[.?!":\-,]/g, "")?.toLowerCase(); - return { - text, - textWithCommit: text + word.commit, - commit: word.commit, - display: - text + (commitCharsToDisplay.has(word.commit) ? word.commit : ""), - sectionIndex: word.sectionIndex, - }; + return this.createFullWord({ ...word, text }); } else { return word; } } } + getCurrent(): Word | undefined { return this.list[TestState.activeWordIndex]; } - push(word: string, sectionIndex: number): Word { - let commit: CommitChar = ""; - if (word.endsWith(" ")) { - commit = " "; - word = word.slice(0, -1); - } else if (word.endsWith("\n")) { - commit = "\n"; - word = word.slice(0, -1); - } - const wordObj = { - text: word, - textWithCommit: word + commit, - commit, - display: word + (commitCharsToDisplay.has(commit) ? commit : ""), - sectionIndex, - }; + + push(word: WordMinimal): Word { + const wordObj = this.createFullWord({ ...word }); this.list.push(wordObj); this.length = this.list.length; @@ -74,6 +79,10 @@ class Words { reset(): void { this.list = []; this.length = 0; + this.haveNumbers = false; + this.haveNewlines = false; + this.haveTabs = false; + this.koreanStatus = false; } removeCommitCharacterFromLastWord(): void { diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ca15f28d2940..c7d0a910d219 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -106,9 +106,7 @@ export function instantHide(): void { function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { - const currentSectionIndex = TestWords.words.get( - TestState.activeWordIndex, - )?.sectionIndex; + const currentSectionIndex = TestWords.words.getCurrent()?.sectionIndex; if (currentSectionIndex === undefined) { return 0; diff --git a/frontend/src/ts/test/words-generator.ts b/frontend/src/ts/test/words-generator.ts index e0786537b26d..bfcebec0503e 100644 --- a/frontend/src/ts/test/words-generator.ts +++ b/frontend/src/ts/test/words-generator.ts @@ -597,15 +597,19 @@ async function getQuoteWordList( return currentQuote.textSplit; } +const koreanRegex = + /[\u1100-\u11ff\u3130-\u318f\ua960-\ua97f\uac00-\ud7af\ud7b0-\ud7ff]/; + let currentWordset: Wordset | null = null; let currentLanguage: LanguageObject | null = null; let isCurrentlyUsingFunboxSection = false; type GenerateWordsReturn = { - words: string[]; - sectionIndexes: number[]; + words: TestWords.WordMinimal[]; hasTab: boolean; hasNewline: boolean; + hasNumbers: boolean; + koreanStatus: boolean; allRightToLeft?: boolean; allJoiningScript?: boolean; }; @@ -627,9 +631,10 @@ export async function generateWords( const rawWordList: string[] = []; const ret: GenerateWordsReturn = { words: [], - sectionIndexes: [], hasTab: false, hasNewline: false, + hasNumbers: false, + koreanStatus: false, allRightToLeft: language.rightToLeft, allJoiningScript: language.joiningScript ?? false, }; @@ -680,6 +685,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; } @@ -694,19 +707,19 @@ export async function generateWords( Arrays.nthElementFromArray(rawWordList, -2) ?? "", ); rawWordList.push(nextWord.wordRaw); - 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++; @@ -718,16 +731,20 @@ export async function generateWords( throw new WordGenError("Random quote is null"); } + // we need to test both because ret.words has only first 100 words + // and currentWordset.words may be changed inside getNextWord() by funboxes 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.words.some((w) => w.text.includes("\t")) || + currentWordset.words.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.words.some((w) => w.text.includes("\n")) || + currentWordset.words.some((w) => w.includes("\n")); + ret.hasNumbers = + ret.words.some((w) => /\d/g.test(w.text)) || + currentWordset.words.some((w) => /\d/g.test(w)); + ret.koreanStatus = + ret.words.some((w) => koreanRegex.test(w.text.normalize())) || + currentWordset.words.some((w) => koreanRegex.test(w.normalize())); sectionHistory = []; //free up a bit of memory? is that even a thing? return ret; @@ -739,10 +756,8 @@ let sectionHistory: string[] = []; let previousGetNextWordReturns: GetNextWordReturn[] = []; -type GetNextWordReturn = { - word: string; +type GetNextWordReturn = TestWords.WordMinimal & { wordRaw: string; - sectionIndex: number; }; //generate next word @@ -974,10 +989,20 @@ 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 textWithCommit = getTextWithCommitChar(randomWord); const ret = { - word: appendCommitCharacter(randomWord), wordRaw: randomWord, - sectionIndex: sectionIndex, + text: textWithCommit.text, + commit: textWithCommit.commit, + direction, + sectionIndex, }; previousGetNextWordReturns.push(ret); @@ -986,16 +1011,21 @@ export async function getNextWord( } /** - * Appends the inter-word commit separator the way the generator does: a trailing + * get the inter-word commit separator the way the generator does: a trailing * space, unless the word already ends with a newline or the nospace funbox is * active. Callers that push words outside of getNextWord (e.g. section funbox * pulls) must use this so the separator is part of the target word. */ -export function appendCommitCharacter(word: string): string { - if (word.endsWith("\n") || isFunboxActiveWithProperty("nospace")) { - return word; +export function getTextWithCommitChar(word: string): { + text: string; + commit: TestWords.CommitChar; +} { + const match = /(.*?)( |\n|)$/.exec(word) as RegExpExecArray; + let commit = match[2] as TestWords.CommitChar; + if (commit === "" && !isFunboxActiveWithProperty("nospace")) { + commit = " "; } - return `${word} `; + return { text: match[1] as string, commit }; } export function areAllWordsGenerated(): boolean { diff --git a/frontend/src/ts/utils/direction-regex.ts b/frontend/src/ts/utils/direction-regex.ts new file mode 100644 index 000000000000..ff0dfb78a2cd --- /dev/null +++ b/frontend/src/ts/utils/direction-regex.ts @@ -0,0 +1,10 @@ +// https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt +// corresponds to the strong R, AL BIDI classes +export const STRONG_RTL_TYPE = + /[\u05BE\u05C0\u05C3\u05C6\u05D0-\u05EA\u05EF-\u05F4\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u070D\u070F-\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4-\u07F5\u07FA\u07FE-\u0815\u081A\u0824\u0828\u0830-\u083E\u0840-\u0858\u085E\u0860-\u086A\u0870-\u088F\u08A0-\u08C9\u200F\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\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{1089E}\u{108A7}-\u{108AF}\u{108E0}-\u{108F2}\u{108F4}-\u{108F5}\u{108FB}-\u{1091B}\u{10920}-\u{10939}\u{1093F}-\u{10959}\u{10980}-\u{109B7}\u{109BC}-\u{109CF}\u{109D2}-\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{10A9F}\u{10AC0}-\u{10AE4}\u{10AEB}-\u{10AF6}\u{10B00}-\u{10B35}\u{10B40}-\u{10B55}\u{10B58}-\u{10B72}\u{10B78}-\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{10D23}\u{10D4A}-\u{10D65}\u{10D6F}-\u{10D85}\u{10D8E}-\u{10D8F}\u{10E80}-\u{10EA9}\u{10EAD}\u{10EB0}-\u{10EB1}\u{10EC2}-\u{10EC7}\u{10F00}-\u{10F27}\u{10F30}-\u{10F45}\u{10F51}-\u{10F59}\u{10F70}-\u{10F81}\u{10F86}-\u{10F89}\u{10FB0}-\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{1ECB4}\u{1ED01}-\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; +// corresponds to the strong L BIDI class +export const STRONG_LTR_TYPE = + /[\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u02BB-\u02C1\u02D0-\u02D1\u02E0-\u02E4\u02EE\u0370-\u0373\u0376-\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0482\u048A-\u052F\u0531-\u0556\u0559-\u0589\u0903-\u0939\u093B\u093D-\u0940\u0949-\u094C\u094E-\u0950\u0958-\u0961\u0964-\u0980\u0982-\u0983\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD-\u09C0\u09C7-\u09C8\u09CB-\u09CC\u09CE\u09D7\u09DC-\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\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-\u0AC0\u0AC9\u0ACB-\u0ACC\u0AD0\u0AE0-\u0AE1\u0AE6-\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-\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-\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-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCB\u0CD5-\u0CD6\u0CDC-\u0CDE\u0CE0-\u0CE1\u0CE6-\u0CEF\u0CF1-\u0CF3\u0D02-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D-\u0D40\u0D46-\u0D48\u0D4A-\u0D4C\u0D4E-\u0D4F\u0D54-\u0D61\u0D66-\u0D7F\u0D82-\u0D83\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DCF-\u0DD1\u0DD8-\u0DDF\u0DE6-\u0DEF\u0DF2-\u0DF4\u0E01-\u0E30\u0E32-\u0E33\u0E40-\u0E46\u0E4F-\u0E5B\u0E81-\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00-\u0F17\u0F1A-\u0F34\u0F36\u0F38\u0F3E-\u0F47\u0F49-\u0F6C\u0F7F\u0F85\u0F88-\u0F8C\u0FBE-\u0FC5\u0FC7-\u0FCC\u0FCE-\u0FDA\u1000-\u102C\u1031\u1038\u103B-\u103C\u103F-\u1057\u105A-\u105D\u1061-\u1070\u1075-\u1081\u1083-\u1084\u1087-\u108C\u108E-\u109C\u109E-\u10C5\u10C7\u10CD\u10D0-\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-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u167F\u1681-\u169A\u16A0-\u16F8\u1700-\u1711\u1715\u171F-\u1731\u1734-\u1736\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17B6\u17BE-\u17C5\u17C7-\u17C8\u17D4-\u17DA\u17DC\u17E0-\u17E9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1923-\u1926\u1929-\u192B\u1930-\u1931\u1933-\u1938\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A19-\u1A1A\u1A1E-\u1A55\u1A57\u1A61\u1A63-\u1A64\u1A6D-\u1A72\u1A80-\u1A89\u1A90-\u1A99\u1AA0-\u1AAD\u1B04-\u1B33\u1B35\u1B3B\u1B3D-\u1B41\u1B43-\u1B4C\u1B4E-\u1B6A\u1B74-\u1B7F\u1B82-\u1BA1\u1BA6-\u1BA7\u1BAA\u1BAE-\u1BE5\u1BE7\u1BEA-\u1BEC\u1BEE\u1BF2-\u1BF3\u1BFC-\u1C2B\u1C34-\u1C35\u1C3B-\u1C49\u1C4D-\u1C8A\u1C90-\u1CBA\u1CBD-\u1CC7\u1CD3\u1CE1\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5-\u1CF7\u1CFA\u1D00-\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-\u2139\u213C-\u213F\u2145-\u2149\u214E-\u214F\u2160-\u2188\u2336-\u237A\u2395\u249C-\u24E9\u26AC\u2800-\u28FF\u2C00-\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-\u3007\u3021-\u3029\u302E-\u302F\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3190-\u31BF\u31F0-\u321C\u3220-\u324F\u3260-\u327B\u327F-\u32B0\u32C0-\u32CB\u32D0-\u3376\u337B-\u33DD\u33E0-\u33FE\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA60C\uA610-\uA62B\uA640-\uA66E\uA680-\uA69D\uA6A0-\uA6EF\uA6F2-\uA6F7\uA722-\uA787\uA789-\uA7DC\uA7F1-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA824\uA827\uA830-\uA837\uA840-\uA873\uA880-\uA8C3\uA8CE-\uA8D9\uA8F2-\uA8FE\uA900-\uA925\uA92E-\uA946\uA952-\uA953\uA95F-\uA97C\uA983-\uA9B2\uA9B4-\uA9B5\uA9BA-\uA9BB\uA9BE-\uA9CD\uA9CF-\uA9D9\uA9DE-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA2F-\uAA30\uAA33-\uAA34\uAA40-\uAA42\uAA44-\uAA4B\uAA4D\uAA50-\uAA59\uAA5C-\uAA7B\uAA7D-\uAAAF\uAAB1\uAAB5-\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAAEB\uAAEE-\uAAF5\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB69\uAB70-\uABE4\uABE6-\uABE7\uABE9-\uABEC\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uE000-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\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{10323}\u{1032D}-\u{1034A}\u{10350}-\u{10375}\u{10380}-\u{1039D}\u{1039F}-\u{103C3}\u{103C8}-\u{103D5}\u{10400}-\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{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{11037}\u{11047}-\u{1104D}\u{11066}-\u{1106F}\u{11071}-\u{11072}\u{11075}\u{11082}-\u{110B2}\u{110B7}-\u{110B8}\u{110BB}-\u{110C1}\u{110CD}\u{110D0}-\u{110E8}\u{110F0}-\u{110F9}\u{11103}-\u{11126}\u{1112C}\u{11136}-\u{11147}\u{11150}-\u{11172}\u{11174}-\u{11176}\u{11182}-\u{111B5}\u{111BF}-\u{111C8}\u{111CD}-\u{111CE}\u{111D0}-\u{111DF}\u{111E1}-\u{111F4}\u{11200}-\u{11211}\u{11213}-\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{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{1133F}\u{11341}-\u{11344}\u{11347}-\u{11348}\u{1134B}-\u{1134D}\u{11350}\u{11357}\u{1135D}-\u{11363}\u{11380}-\u{11389}\u{1138B}\u{1138E}\u{11390}-\u{113B5}\u{113B7}-\u{113BA}\u{113C2}\u{113C5}\u{113C7}-\u{113CA}\u{113CC}-\u{113CD}\u{113CF}\u{113D1}\u{113D3}-\u{113D5}\u{113D7}-\u{113D8}\u{11400}-\u{11437}\u{11440}-\u{11441}\u{11445}\u{11447}-\u{1145B}\u{1145D}\u{1145F}-\u{11461}\u{11480}-\u{114B2}\u{114B9}\u{114BB}-\u{114BE}\u{114C1}\u{114C4}-\u{114C7}\u{114D0}-\u{114D9}\u{11580}-\u{115B1}\u{115B8}-\u{115BB}\u{115BE}\u{115C1}-\u{115DB}\u{11600}-\u{11632}\u{1163B}-\u{1163C}\u{1163E}\u{11641}-\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{11746}\u{11800}-\u{1182E}\u{11838}\u{1183B}\u{118A0}-\u{118F2}\u{118FF}-\u{11906}\u{11909}\u{1190C}-\u{11913}\u{11915}-\u{11916}\u{11918}-\u{11935}\u{11937}-\u{11938}\u{1193D}\u{1193F}-\u{11942}\u{11944}-\u{11946}\u{11950}-\u{11959}\u{119A0}-\u{119A7}\u{119AA}-\u{119D3}\u{119DC}-\u{119DF}\u{119E1}-\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{11AA2}\u{11AB0}-\u{11AF8}\u{11B00}-\u{11B09}\u{11B61}\u{11B65}\u{11B67}\u{11BC0}-\u{11BE1}\u{11BF0}-\u{11BF9}\u{11C00}-\u{11C08}\u{11C0A}-\u{11C2F}\u{11C3E}-\u{11C45}\u{11C50}-\u{11C6C}\u{11C70}-\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{11D8E}\u{11D93}-\u{11D94}\u{11D96}\u{11D98}\u{11DA0}-\u{11DA9}\u{11DB0}-\u{11DDB}\u{11DE0}-\u{11DE9}\u{11EE0}-\u{11EF2}\u{11EF5}-\u{11EF8}\u{11F02}-\u{11F10}\u{11F12}-\u{11F35}\u{11F3E}-\u{11F3F}\u{11F41}\u{11F43}-\u{11F59}\u{11FB0}\u{11FC0}-\u{11FD4}\u{11FFF}-\u{12399}\u{12400}-\u{1246E}\u{12470}-\u{12474}\u{12480}-\u{12543}\u{12F90}-\u{12FF2}\u{13000}-\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{16ABE}\u{16AC0}-\u{16AC9}\u{16AD0}-\u{16AED}\u{16AF5}\u{16B00}-\u{16B2F}\u{16B37}-\u{16B45}\u{16B50}-\u{16B59}\u{16B5B}-\u{16B61}\u{16B63}-\u{16B77}\u{16B7D}-\u{16B8F}\u{16D40}-\u{16D79}\u{16E40}-\u{16E9A}\u{16EA0}-\u{16EB8}\u{16EBB}-\u{16ED3}\u{16F00}-\u{16F4A}\u{16F50}-\u{16F87}\u{16F93}-\u{16F9F}\u{16FE0}-\u{16FE1}\u{16FE3}\u{16FF0}-\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{1D166}\u{1D16A}-\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{1DA8B}\u{1DF00}-\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{1E4EB}\u{1E4F0}-\u{1E4F9}\u{1E5D0}-\u{1E5ED}\u{1E5F0}-\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; +// corresponds to the weak EN, AN BIDI classes +export const NUMERAL_TYPE = + /[\u0030-\u0039\u00B2-\u00B3\u00B9\u0600-\u0605\u0660-\u0669\u066B-\u066C\u06DD\u06F0-\u06F9\u0890-\u0891\u08E2\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFF10-\uFF19\u{102E1}-\u{102FB}\u{10D30}-\u{10D39}\u{10D40}-\u{10D49}\u{10E60}-\u{10E7E}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}]/u; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index ebe1103b674b..f5bc4af5257b 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -1,4 +1,9 @@ import { Language } from "@monkeytype/schemas/languages"; +import { + STRONG_RTL_TYPE, + STRONG_LTR_TYPE, + NUMERAL_TYPE, +} from "./direction-regex"; /** * Removes accents from a string. @@ -227,67 +232,57 @@ export function replaceControlCharacters(textToClear: string): string { return textToClear; } +export type Direction = "ltr" | "rtl"; + +// Cache for word direction to avoid repeated calculations per word +const wordDirectionCache: Map = new Map(); + /** - * 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 + * 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. */ -function hasRTLCharacters(word: string): [boolean, number] { - if (!word || word.length === 0) { - return [false, 0]; +export function getWordDirection( + word?: string, + fallback: Direction = "ltr", +): Direction { + if (word === undefined || word.length === 0) return fallback; + + const cachedDirection = wordDirectionCache.get(word); + if (cachedDirection !== undefined) return cachedDirection ?? fallback; + + /* cache miss */ + + const firstRTLChar = STRONG_RTL_TYPE.exec(word)?.index ?? Infinity; + const firstLTRChar = STRONG_LTR_TYPE.exec(word)?.index ?? Infinity; + + let direction: Direction | null; + if (firstRTLChar === Infinity && firstLTRChar === Infinity) { + // word has no characters with strong type, check for numerals + if (NUMERAL_TYPE.test(word)) direction = "ltr"; + else direction = null; + } else if (firstRTLChar < firstLTRChar) { + // first char with strong type is rtl + direction = "rtl"; + } else { + direction = "ltr"; } - // 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]; + wordDirectionCache.set(word, direction); + return direction ?? fallback; } /** - * Cache for word direction to avoid repeated calculations per word - * Keyed by the stripped core of the word; can be manually cleared when needed + * Reverses "ltr" and "rtl" directions. Keeps it as is otherwise. + * @param direction direction to reverse + * @returns reversed direction. */ -let wordDirectionCache: Map = new Map(); - -export function clearWordDirectionCache(): void { - wordDirectionCache.clear(); -} - -export function isWordRightToLeft( - word: string | undefined, - languageRTL: boolean, - reverseDirection?: boolean, -): [boolean, boolean] { - if (word === undefined || word.length === 0) { - return reverseDirection ? [!languageRTL, false] : [languageRTL, false]; - } - - // 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]; - } - - // 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]; - } - - const result = hasRTLCharacters(core); - wordDirectionCache.set(core, result); - - return reverseDirection - ? [!result[0], false] - : [result[0], result[1] === word.length]; +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 = [ @@ -466,8 +461,3 @@ export function countChars( missed, }; } - -// Export testing utilities for unit tests -export const __testing = { - hasRTLCharacters, -}; diff --git a/frontend/static/funbox/backwards.css b/frontend/static/funbox/backwards.css index 3591008f5669..1295fa642a96 100644 --- a/frontend/static/funbox/backwards.css +++ b/frontend/static/funbox/backwards.css @@ -5,7 +5,3 @@ #words.rightToLeftTest { direction: ltr; } - -#words.joiningScript .word { - unicode-bidi: bidi-override; -}