From fa18337a63f1f1f47120f1c866ed1e262c54da96 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:33:05 +0200 Subject: [PATCH 1/6] Fix caret problems when typing ltr characters in rtl test --- frontend/src/styles/test.scss | 8 ++++++++ frontend/src/ts/utils/caret.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 82bfdb89c..d0064f2c7 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -319,6 +319,10 @@ &.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 { @@ -789,6 +793,10 @@ &.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/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts index 47408db78..3557ede14 100644 --- a/frontend/src/ts/utils/caret.ts +++ b/frontend/src/ts/utils/caret.ts @@ -455,6 +455,7 @@ export class Caret { // yes, this is all super verbose, but its easier to maintain and understand if (isWordRTL) { + options.word.addClass("wordRtl"); let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { From 4edab4794e5c177428599a2dd65df674fbd4fe58 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:54:29 +0200 Subject: [PATCH 2/6] Handle zen mode --- frontend/src/ts/utils/caret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts index 3557ede14..471f8c99e 100644 --- a/frontend/src/ts/utils/caret.ts +++ b/frontend/src/ts/utils/caret.ts @@ -455,7 +455,7 @@ export class Caret { // yes, this is all super verbose, but its easier to maintain and understand if (isWordRTL) { - options.word.addClass("wordRtl"); + if (Config.mode !== "zen") options.word.addClass("wordRtl"); let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { From 3353374bb900b2059157c235897a9e062a606401 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:24:44 +0200 Subject: [PATCH 3/6] More general --- frontend/src/ts/utils/caret.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts index 471f8c99e..60629c6bd 100644 --- a/frontend/src/ts/utils/caret.ts +++ b/frontend/src/ts/utils/caret.ts @@ -455,7 +455,7 @@ export class Caret { // yes, this is all super verbose, but its easier to maintain and understand if (isWordRTL) { - if (Config.mode !== "zen") options.word.addClass("wordRtl"); + if (options.wordText) options.word.addClass("wordRtl"); let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { From 9c2d7200cacb653d5b190dd902cc58ac5c6cf020 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:19:54 +0200 Subject: [PATCH 4/6] Improve logic --- frontend/src/ts/test/test-ui.ts | 2 +- frontend/src/ts/utils/caret.ts | 4 ++-- frontend/src/ts/utils/strings.ts | 27 +++++++++++++++++---------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 86dba54a5..de22c1ea1 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -217,7 +217,7 @@ async function joinOverlappingHints( activeWordLetters: NodeListOf, hintElements: HTMLCollection, ): Promise { - const isWordRightToLeft = Strings.isWordRightToLeft( + const [isWordRightToLeft, _isFullMatch] = Strings.isWordRightToLeft( TestWords.words.getCurrent(), TestState.isLanguageRightToLeft, TestState.isDirectionReversed, diff --git a/frontend/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts index 60629c6bd..11935474a 100644 --- a/frontend/src/ts/utils/caret.ts +++ b/frontend/src/ts/utils/caret.ts @@ -405,7 +405,7 @@ export class Caret { isLanguageRightToLeft: boolean; isDirectionReversed: boolean; }): { left: number; top: number; width: number } { - const isWordRTL = isWordRightToLeft( + const [isWordRTL, isFullMatch] = isWordRightToLeft( options.wordText, options.isLanguageRightToLeft, options.isDirectionReversed, @@ -455,7 +455,7 @@ export class Caret { // yes, this is all super verbose, but its easier to maintain and understand if (isWordRTL) { - if (options.wordText) options.word.addClass("wordRtl"); + if (isFullMatch) options.word.addClass("wordRtl"); let afterLetterCorrection = 0; if (options.side === "afterLetter") { if (this.isFullWidth()) { diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 993f49682..bf9e79f3f 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -214,23 +214,24 @@ export function replaceControlCharacters(textToClear: string): string { * @param word the word to check for RTL characters * @returns true if the word contains RTL characters, false otherwise */ -function hasRTLCharacters(word: string): boolean { +function hasRTLCharacters(word: string): [boolean, number] { if (!word || word.length === 0) { - return false; + return [false, 0]; } // This covers Arabic, Farsi, Urdu, and other RTL scripts const rtlPattern = - /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/; + /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]+/; - return rtlPattern.test(word); + const result = rtlPattern.exec(word); + return [result !== null, result?.[0].length ?? 0]; } /** * Cache for word direction to avoid repeated calculations per word * Keyed by the stripped core of the word; can be manually cleared when needed */ -let wordDirectionCache: Map = new Map(); +let wordDirectionCache: Map = new Map(); export function clearWordDirectionCache(): void { wordDirectionCache.clear(); @@ -240,25 +241,31 @@ export function isWordRightToLeft( word: string | undefined, languageRTL: boolean, reverseDirection?: boolean, -): boolean { +): [boolean, boolean] { if (word === undefined || word.length === 0) { - return reverseDirection ? !languageRTL : languageRTL; + 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 : languageRTL; + 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 : cached; + 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 : result; + return reverseDirection + ? [!result[0], false] + : [result[0], result[1] === word.length]; } export const CHAR_EQUIVALENCE_SETS = [ From 9c7f5f1a988a279da0ff9a9ba96b9c39bb43fa52 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:17:54 +0200 Subject: [PATCH 5/6] Fix tests --- frontend/__tests__/utils/strings.spec.ts | 54 ++++++++++++------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index cae1aeef9..8fa02f4c5 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -265,7 +265,7 @@ describe("string utils", () => { ] as const)( "should return %s for word '%s' (%s)", (expected: boolean, word: string, _description: string) => { - expect(Strings.__testing.hasRTLCharacters(word)).toBe(expected); + expect(Strings.__testing.hasRTLCharacters(word)[0]).toBe(expected); }, ); }); @@ -321,27 +321,27 @@ describe("string utils", () => { languageRTL: boolean, _description: string, ) => { - expect(Strings.isWordRightToLeft(word, languageRTL)).toBe(expected); + expect(Strings.isWordRightToLeft(word, languageRTL)[0]).toBe(expected); }, ); it("should return languageRTL for undefined word", () => { - expect(Strings.isWordRightToLeft(undefined, false)).toBe(false); - expect(Strings.isWordRightToLeft(undefined, true)).toBe(true); + expect(Strings.isWordRightToLeft(undefined, false)[0]).toBe(false); + expect(Strings.isWordRightToLeft(undefined, true)[0]).toBe(true); }); // testing reverseDirection it("should return true for LTR word with reversed direction", () => { - expect(Strings.isWordRightToLeft("hello", false, true)).toBe(true); - expect(Strings.isWordRightToLeft("hello", true, true)).toBe(true); + 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)).toBe(false); - expect(Strings.isWordRightToLeft("مرحبا", false, true)).toBe(false); + 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)).toBe(true); - expect(Strings.isWordRightToLeft(undefined, true, true)).toBe(false); + expect(Strings.isWordRightToLeft(undefined, false, true)[0]).toBe(true); + expect(Strings.isWordRightToLeft(undefined, true, true)[0]).toBe(false); }); describe("caching", () => { @@ -364,8 +364,8 @@ describe("string utils", () => { it("should use cache for repeated calls", () => { // First call should cache the result (cache miss) const result1 = Strings.isWordRightToLeft("hello", false); - expect(result1).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("hello", false); + expect(result1[0]).toBe(false); + expect(mapSetSpy).toHaveBeenCalledWith("hello", [false, 0]); // Reset spies to check second call mapGetSpy.mockClear(); @@ -373,7 +373,7 @@ describe("string utils", () => { // Second call should use cache (cache hit) const result2 = Strings.isWordRightToLeft("hello", false); - expect(result2).toBe(false); + expect(result2[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again @@ -382,7 +382,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); const result3 = Strings.isWordRightToLeft("hello", true); - expect(result3).toBe(false); // Still false because "hello" is LTR regardless of language + expect(result3[0]).toBe(false); // Still false because "hello" is LTR regardless of language expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again }); @@ -390,15 +390,15 @@ describe("string utils", () => { 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).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("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).toBe(false); + expect(result2[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); @@ -406,7 +406,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); const result3 = Strings.isWordRightToLeft("!hello", false); - expect(result3).toBe(false); + expect(result3[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); @@ -414,7 +414,7 @@ describe("string utils", () => { mapSetSpy.mockClear(); const result4 = Strings.isWordRightToLeft("!hello!", false); - expect(result4).toBe(false); + expect(result4[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("hello"); expect(mapSetSpy).not.toHaveBeenCalled(); }); @@ -422,7 +422,7 @@ describe("string utils", () => { it("should handle cache clearing", () => { // Cache a result Strings.isWordRightToLeft("test", false); - expect(mapSetSpy).toHaveBeenCalledWith("test", false); + expect(mapSetSpy).toHaveBeenCalledWith("test", [false, 0]); // Clear cache Strings.clearWordDirectionCache(); @@ -434,23 +434,23 @@ describe("string utils", () => { // Should work normally after cache clear (cache miss again) const result = Strings.isWordRightToLeft("test", false); - expect(result).toBe(false); - expect(mapSetSpy).toHaveBeenCalledWith("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).toBe(false); + expect(result1[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("unique"); - expect(mapSetSpy).toHaveBeenCalledWith("unique", false); + expect(mapSetSpy).toHaveBeenCalledWith("unique", [false, 0]); mapGetSpy.mockClear(); mapSetSpy.mockClear(); // Test cache hit - same word again const result2 = Strings.isWordRightToLeft("unique", false); - expect(result2).toBe(false); + expect(result2[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("unique"); expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit @@ -459,9 +459,9 @@ describe("string utils", () => { // Test cache miss - different word const result3 = Strings.isWordRightToLeft("different", false); - expect(result3).toBe(false); + expect(result3[0]).toBe(false); expect(mapGetSpy).toHaveBeenCalledWith("different"); - expect(mapSetSpy).toHaveBeenCalledWith("different", false); + expect(mapSetSpy).toHaveBeenCalledWith("different", [false, 0]); }); }); }); From b8f66e461b47a3880ca95f3d5297cc0dfdbdd0b0 Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:22:42 +0200 Subject: [PATCH 6/6] Fix some errors --- frontend/src/ts/utils/strings.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index bf9e79f3f..4ec735b67 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -250,15 +250,17 @@ export function isWordRightToLeft( // 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) + 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) + if (cached !== undefined) { return reverseDirection ? [!cached[0], false] : [cached[0], cached[1] === word.length]; + } const result = hasRTLCharacters(core); wordDirectionCache.set(core, result);