Skip to content

Commit 01d8363

Browse files
byseif21Miodec
andauthored
impr(caret): handle mixed language direction (@byseif21) (monkeytypegame#6695)
### Description enhances the caret positioning logic to support mixed language directions (LTR and RTL) within words. It introduces a new hasRTLCharacters utility function to detect RTL characters in individual words, allowing the caret to adjust dynamically based on word-specific direction rather than relying solely on the language's default direction #### notes: * tested no affect to the normal single direction. * no tap mode handle included * related monkeytypegame#6694 monkeytypegame#6666 --------- Co-authored-by: Jack <[email protected]>
1 parent 64473e4 commit 01d8363

File tree

5 files changed

+332
-11
lines changed

5 files changed

+332
-11
lines changed

frontend/__tests__/utils/strings.spec.ts

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import * as Strings from "../../src/ts/utils/strings";
33

44
describe("string utils", () => {
@@ -66,4 +66,239 @@ describe("string utils", () => {
6666
}
6767
);
6868
});
69+
70+
describe("hasRTLCharacters", () => {
71+
it.each([
72+
// LTR characters should return false
73+
[false, "hello", "basic Latin text"],
74+
[false, "world123", "Latin text with numbers"],
75+
[false, "test!", "Latin text with punctuation"],
76+
[false, "ABC", "uppercase Latin text"],
77+
[false, "", "empty string"],
78+
[false, "123", "numbers only"],
79+
[false, "!@#$%", "punctuation and symbols only"],
80+
[false, " ", "whitespace only"],
81+
82+
// Common LTR scripts
83+
[false, "Здравствуй", "Cyrillic text"],
84+
[false, "Bonjour", "Latin with accents"],
85+
[false, "Καλημέρα", "Greek text"],
86+
[false, "こんにちは", "Japanese Hiragana"],
87+
[false, "你好", "Chinese characters"],
88+
[false, "안녕하세요", "Korean text"],
89+
90+
// RTL characters should return true - Arabic
91+
[true, "مرحبا", "Arabic text"],
92+
[true, "السلام", "Arabic phrase"],
93+
[true, "العربية", "Arabic word"],
94+
[true, "٠١٢٣٤٥٦٧٨٩", "Arabic-Indic digits"],
95+
96+
// RTL characters should return true - Hebrew
97+
[true, "שלום", "Hebrew text"],
98+
[true, "עברית", "Hebrew word"],
99+
[true, "ברוך", "Hebrew name"],
100+
101+
// RTL characters should return true - Persian/Farsi
102+
[true, "سلام", "Persian text"],
103+
[true, "فارسی", "Persian word"],
104+
105+
// Mixed content (should return true if ANY RTL characters are present)
106+
[true, "hello مرحبا", "mixed LTR and Arabic"],
107+
[true, "123 שלום", "numbers and Hebrew"],
108+
[true, "test سلام!", "Latin, Persian, and punctuation"],
109+
[true, "مرحبا123", "Arabic with numbers"],
110+
[true, "hello؟", "Latin with Arabic punctuation"],
111+
112+
// Edge cases with various Unicode ranges
113+
[false, "𝕳𝖊𝖑𝖑𝖔", "mathematical bold text (LTR)"],
114+
[false, "🌍🌎🌏", "emoji"],
115+
] as const)(
116+
"should return %s for word '%s' (%s)",
117+
(expected: boolean, word: string, _description: string) => {
118+
expect(Strings.__testing.hasRTLCharacters(word)).toBe(expected);
119+
}
120+
);
121+
});
122+
123+
describe("getWordDirection", () => {
124+
beforeEach(() => {
125+
Strings.clearWordDirectionCache();
126+
});
127+
128+
it.each([
129+
// Basic functionality - should use hasRTLCharacters result when word has core content
130+
[false, "hello", false, "LTR word in LTR language"],
131+
[
132+
false,
133+
"hello",
134+
true,
135+
"LTR word in RTL language (word direction overrides language)",
136+
],
137+
[
138+
true,
139+
"مرحبا",
140+
false,
141+
"RTL word in LTR language (word direction overrides language)",
142+
],
143+
[true, "مرحبا", true, "RTL word in RTL language"],
144+
145+
// Punctuation stripping behavior
146+
[false, "hello!", false, "LTR word with trailing punctuation"],
147+
[false, "!hello", false, "LTR word with leading punctuation"],
148+
[false, "!hello!", false, "LTR word with surrounding punctuation"],
149+
[true, "مرحبا؟", false, "RTL word with trailing punctuation"],
150+
[true, "؟مرحبا", false, "RTL word with leading punctuation"],
151+
[true, "؟مرحبا؟", false, "RTL word with surrounding punctuation"],
152+
153+
// Fallback to language direction for empty/neutral content
154+
[false, "", false, "empty string falls back to LTR language"],
155+
[true, "", true, "empty string falls back to RTL language"],
156+
[false, "!!!", false, "punctuation only falls back to LTR language"],
157+
[true, "!!!", true, "punctuation only falls back to RTL language"],
158+
[false, " ", false, "whitespace only falls back to LTR language"],
159+
[true, " ", true, "whitespace only falls back to RTL language"],
160+
161+
// Numbers behavior (numbers are neutral, follow hasRTLCharacters detection)
162+
[false, "123", false, "regular digits are not RTL"],
163+
[false, "123", true, "regular digits are not RTL regardless of language"],
164+
[true, "١٢٣", false, "Arabic-Indic digits are detected as RTL"],
165+
[true, "١٢٣", true, "Arabic-Indic digits are detected as RTL"],
166+
] as const)(
167+
"should return %s for word '%s' with languageRTL=%s (%s)",
168+
(
169+
expected: boolean,
170+
word: string,
171+
languageRTL: boolean,
172+
_description: string
173+
) => {
174+
expect(Strings.getWordDirection(word, languageRTL)).toBe(expected);
175+
}
176+
);
177+
178+
it("should return languageRTL for undefined word", () => {
179+
expect(Strings.getWordDirection(undefined, false)).toBe(false);
180+
expect(Strings.getWordDirection(undefined, true)).toBe(true);
181+
});
182+
183+
describe("caching", () => {
184+
let mapGetSpy: ReturnType<typeof vi.spyOn>;
185+
let mapSetSpy: ReturnType<typeof vi.spyOn>;
186+
let mapClearSpy: ReturnType<typeof vi.spyOn>;
187+
188+
beforeEach(() => {
189+
mapGetSpy = vi.spyOn(Map.prototype, "get");
190+
mapSetSpy = vi.spyOn(Map.prototype, "set");
191+
mapClearSpy = vi.spyOn(Map.prototype, "clear");
192+
});
193+
194+
afterEach(() => {
195+
mapGetSpy.mockRestore();
196+
mapSetSpy.mockRestore();
197+
mapClearSpy.mockRestore();
198+
});
199+
200+
it("should use cache for repeated calls", () => {
201+
// First call should cache the result (cache miss)
202+
const result1 = Strings.getWordDirection("hello", false);
203+
expect(result1).toBe(false);
204+
expect(mapSetSpy).toHaveBeenCalledWith("hello", false);
205+
206+
// Reset spies to check second call
207+
mapGetSpy.mockClear();
208+
mapSetSpy.mockClear();
209+
210+
// Second call should use cache (cache hit)
211+
const result2 = Strings.getWordDirection("hello", false);
212+
expect(result2).toBe(false);
213+
expect(mapGetSpy).toHaveBeenCalledWith("hello");
214+
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
215+
216+
// Cache should work regardless of language direction for same word
217+
mapGetSpy.mockClear();
218+
mapSetSpy.mockClear();
219+
220+
const result3 = Strings.getWordDirection("hello", true);
221+
expect(result3).toBe(false); // Still false because "hello" is LTR regardless of language
222+
expect(mapGetSpy).toHaveBeenCalledWith("hello");
223+
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
224+
});
225+
226+
it("should cache based on core word without punctuation", () => {
227+
// First call should cache the result for core "hello"
228+
const result1 = Strings.getWordDirection("hello", false);
229+
expect(result1).toBe(false);
230+
expect(mapSetSpy).toHaveBeenCalledWith("hello", false);
231+
232+
mapGetSpy.mockClear();
233+
mapSetSpy.mockClear();
234+
235+
// These should all use the same cache entry since they have the same core
236+
const result2 = Strings.getWordDirection("hello!", false);
237+
expect(result2).toBe(false);
238+
expect(mapGetSpy).toHaveBeenCalledWith("hello");
239+
expect(mapSetSpy).not.toHaveBeenCalled();
240+
241+
mapGetSpy.mockClear();
242+
mapSetSpy.mockClear();
243+
244+
const result3 = Strings.getWordDirection("!hello", false);
245+
expect(result3).toBe(false);
246+
expect(mapGetSpy).toHaveBeenCalledWith("hello");
247+
expect(mapSetSpy).not.toHaveBeenCalled();
248+
249+
mapGetSpy.mockClear();
250+
mapSetSpy.mockClear();
251+
252+
const result4 = Strings.getWordDirection("!hello!", false);
253+
expect(result4).toBe(false);
254+
expect(mapGetSpy).toHaveBeenCalledWith("hello");
255+
expect(mapSetSpy).not.toHaveBeenCalled();
256+
});
257+
258+
it("should handle cache clearing", () => {
259+
// Cache a result
260+
Strings.getWordDirection("test", false);
261+
expect(mapSetSpy).toHaveBeenCalledWith("test", false);
262+
263+
// Clear cache
264+
Strings.clearWordDirectionCache();
265+
expect(mapClearSpy).toHaveBeenCalled();
266+
267+
mapGetSpy.mockClear();
268+
mapSetSpy.mockClear();
269+
mapClearSpy.mockClear();
270+
271+
// Should work normally after cache clear (cache miss again)
272+
const result = Strings.getWordDirection("test", false);
273+
expect(result).toBe(false);
274+
expect(mapSetSpy).toHaveBeenCalledWith("test", false);
275+
});
276+
277+
it("should demonstrate cache miss vs cache hit behavior", () => {
278+
// Test cache miss - first time seeing this word
279+
const result1 = Strings.getWordDirection("unique", false);
280+
expect(result1).toBe(false);
281+
expect(mapGetSpy).toHaveBeenCalledWith("unique");
282+
expect(mapSetSpy).toHaveBeenCalledWith("unique", false);
283+
284+
mapGetSpy.mockClear();
285+
mapSetSpy.mockClear();
286+
287+
// Test cache hit - same word again
288+
const result2 = Strings.getWordDirection("unique", false);
289+
expect(result2).toBe(false);
290+
expect(mapGetSpy).toHaveBeenCalledWith("unique");
291+
expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit
292+
293+
mapGetSpy.mockClear();
294+
mapSetSpy.mockClear();
295+
296+
// Test cache miss - different word
297+
const result3 = Strings.getWordDirection("different", false);
298+
expect(result3).toBe(false);
299+
expect(mapGetSpy).toHaveBeenCalledWith("different");
300+
expect(mapSetSpy).toHaveBeenCalledWith("different", false);
301+
});
302+
});
303+
});
69304
});

frontend/src/ts/test/caret.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as TestState from "../test/test-state";
66
import * as TestWords from "./test-words";
77
import { prefersReducedMotion } from "../utils/misc";
88
import { convertRemToPixels } from "../utils/numbers";
9-
import { splitIntoCharacters } from "../utils/strings";
9+
import { splitIntoCharacters, getWordDirection } from "../utils/strings";
1010
import { safeNumber } from "@monkeytype/util/numbers";
1111
import { subscribe } from "../observables/config-event";
1212

@@ -59,19 +59,26 @@ function getTargetPositionLeft(
5959
currentWordNodeList: NodeListOf<HTMLElement>,
6060
fullWidthCaretWidth: number,
6161
wordLen: number,
62-
inputLen: number
62+
inputLen: number,
63+
currentWord?: string
6364
): number {
6465
const invisibleExtraLetters = Config.blindMode || Config.hideExtraLetters;
6566
let result = 0;
6667

68+
// use word-specific direction if available and different from language direction
69+
const isWordRightToLeft = getWordDirection(
70+
currentWord,
71+
isLanguageRightToLeft
72+
);
73+
6774
if (Config.tapeMode === "off") {
6875
let positionOffsetToWord = 0;
6976

7077
const currentLetter = currentWordNodeList[inputLen];
7178
const lastWordLetter = currentWordNodeList[wordLen - 1];
7279
const lastInputLetter = currentWordNodeList[inputLen - 1];
7380

74-
if (isLanguageRightToLeft) {
81+
if (isWordRightToLeft) {
7582
if (inputLen <= wordLen && currentLetter) {
7683
// at word beginning in zen mode both lengths are 0, but currentLetter is defined "_"
7784
positionOffsetToWord =
@@ -104,13 +111,13 @@ function getTargetPositionLeft(
104111
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
105112
const tapeMargin =
106113
wordsWrapperWidth *
107-
(isLanguageRightToLeft
114+
(isWordRightToLeft
108115
? 1 - Config.tapeMargin / 100
109116
: Config.tapeMargin / 100);
110117

111118
result =
112119
tapeMargin -
113-
(fullWidthCaret && isLanguageRightToLeft ? fullWidthCaretWidth : 0);
120+
(fullWidthCaret && isWordRightToLeft ? fullWidthCaretWidth : 0);
114121

115122
if (Config.tapeMode === "word" && inputLen > 0) {
116123
let currentWordWidth = 0;
@@ -125,7 +132,7 @@ function getTargetPositionLeft(
125132
// if current letter has zero width move the caret to previous positive width letter
126133
if ($(currentWordNodeList[inputLen] as Element).outerWidth(true) === 0)
127134
currentWordWidth -= lastPositiveLetterWidth;
128-
if (isLanguageRightToLeft) currentWordWidth *= -1;
135+
if (isWordRightToLeft) currentWordWidth *= -1;
129136
result += currentWordWidth;
130137
}
131138
}
@@ -211,14 +218,21 @@ export async function updatePosition(noAnim = false): Promise<void> {
211218
currentWordNodeList
212219
);
213220

221+
// in zen mode, use the input content to determine word direction
222+
const currentWordForDirection =
223+
Config.mode === "zen"
224+
? TestInput.input.current
225+
: TestWords.words.getCurrent();
226+
214227
const letterPosLeft = getTargetPositionLeft(
215228
fullWidthCaret,
216229
isLanguageRightToLeft,
217230
activeWordEl,
218231
currentWordNodeList,
219232
letterWidth,
220233
wordLen,
221-
inputLen
234+
inputLen,
235+
currentWordForDirection
222236
);
223237
const newLeft = letterPosLeft - (fullWidthCaret ? 0 : caretWidth / 2);
224238

frontend/src/ts/test/pace-caret.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as TestState from "./test-state";
99
import * as ConfigEvent from "../observables/config-event";
1010
import { convertRemToPixels } from "../utils/numbers";
1111
import { getActiveFunboxes } from "./funbox/list";
12+
import { getWordDirection } from "../utils/strings";
1213

1314
type Settings = {
1415
wpm: number;
@@ -53,12 +54,19 @@ async function resetCaretPosition(): Promise<void> {
5354
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
5455
const isLanguageRightToLeft = currentLanguage.rightToLeft;
5556

57+
const currentWord = TestWords.words.get(settings?.currentWordIndex ?? 0);
58+
59+
const isWordRightToLeft = getWordDirection(
60+
currentWord,
61+
isLanguageRightToLeft ?? false
62+
);
63+
5664
caret.stop(true, true).animate(
5765
{
5866
top: firstLetter.offsetTop - firstLetterHeight / 4,
5967
left:
6068
firstLetter.offsetLeft +
61-
(isLanguageRightToLeft ? firstLetter.offsetWidth : 0),
69+
(isWordRightToLeft ? firstLetter.offsetWidth : 0),
6270
},
6371
0,
6472
"linear"
@@ -231,6 +239,12 @@ export async function update(expectedStepEnd: number): Promise<void> {
231239
);
232240
const isLanguageRightToLeft = currentLanguage.rightToLeft;
233241

242+
const currentWord = TestWords.words.get(settings.currentWordIndex);
243+
244+
const isWordRightToLeft = getWordDirection(
245+
currentWord,
246+
isLanguageRightToLeft ?? false
247+
);
234248
newTop =
235249
word.offsetTop +
236250
currentLetter.offsetTop -
@@ -240,13 +254,13 @@ export async function update(expectedStepEnd: number): Promise<void> {
240254
word.offsetLeft +
241255
currentLetter.offsetLeft -
242256
caretWidth / 2 +
243-
(isLanguageRightToLeft ? currentLetterWidth : 0);
257+
(isWordRightToLeft ? currentLetterWidth : 0);
244258
} else {
245259
newLeft =
246260
word.offsetLeft +
247261
currentLetter.offsetLeft -
248262
caretWidth / 2 +
249-
(isLanguageRightToLeft ? 0 : currentLetterWidth);
263+
(isWordRightToLeft ? 0 : currentLetterWidth);
250264
}
251265
caret.removeClass("hidden");
252266
} catch (e) {

frontend/src/ts/test/test-logic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ export function restart(options = {} as RestartOptions): void {
162162
};
163163

164164
options = { ...defaultOptions, ...options };
165+
Strings.clearWordDirectionCache();
166+
165167
const animationTime = options.noAnim ? 0 : Misc.applyReducedMotion(125);
166168

167169
const noQuit = isFunboxActive("no_quit");

0 commit comments

Comments
 (0)