fix(funbox): caret/tape/ui issues in backwards funbox (@NadAlaba) (#6956)

### Description

1. refactor:
- store (language direction / state of direction reversing funbox) in
test-state.ts, and set them on `TestLogic.init()` which is called on
each restart which happens on each change of Config.language or funbox.
- use these new direction variables in (caret.ts / test-ui.ts /
pace-caret.ts /result-word-highlight.ts) instead of calling `await
JSONData.getCurrentLanguage(Config.language)`.

2. css changes:
- add `unicode-bidi: bidi-override;` to .words with ligatures in
backwards to fix the direction of LTR words on LTR languages in custom
tests (which now have `.withLigatures` class regardless of language).
- remove `direction: rtl;` from right to left .word and keep it on right
to left #words. This was done because after adding the above
`bidi-override`, (.word)s directioin was being forced to rtl on tests
with RTL language and RTL words (custom and none custom tests), which is
wrong (should be ltr on those tests because of the backwards funbox).
- P.S., removing this from .word does not affect normal tests, because
.word direction is inherited from #words directtion on non
.withLigatures tests (e.g, non custom tests in non withLigatures
languages), and it is calculated using internal browser algorithm based
on characters used in .withLigatures tests (tests in languages with
ligatures and all custom tests).

3. add the property "reverseDirection" to backwards funbox, which
signifies that the direction of the test should be the reverse of the
direction of Config.language, and the direction of a word should be the
reverse of `Strings.isWordRightToLeft()`.

4. allow backwards funbox to work on languages with ligatures.

5. move `void Caret.updatePosition()` call to after the call of
`TestUI.lineJump()` in `input-controller.ts:handleSpace()`.

6. change name of `Strings.getWordDirection()` to
`Strings.isWordRightToLeft()` which explains what does the returned
boolean mean, and add a parameter `reverseDirection` that flips the
final result if true.

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Nad Alaba 2025-09-19 23:15:03 +03:00 committed by GitHub
parent 5026f41954
commit 4318601799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 114 additions and 98 deletions

View file

@ -270,7 +270,7 @@ describe("string utils", () => {
);
});
describe("getWordDirection", () => {
describe("isWordRightToLeft", () => {
beforeEach(() => {
Strings.clearWordDirectionCache();
});
@ -321,13 +321,27 @@ describe("string utils", () => {
languageRTL: boolean,
_description: string
) => {
expect(Strings.getWordDirection(word, languageRTL)).toBe(expected);
expect(Strings.isWordRightToLeft(word, languageRTL)).toBe(expected);
}
);
it("should return languageRTL for undefined word", () => {
expect(Strings.getWordDirection(undefined, false)).toBe(false);
expect(Strings.getWordDirection(undefined, true)).toBe(true);
expect(Strings.isWordRightToLeft(undefined, false)).toBe(false);
expect(Strings.isWordRightToLeft(undefined, true)).toBe(true);
});
// testing reverseDirection
it("should return true for LTR word with reversed direction", () => {
expect(Strings.isWordRightToLeft("hello", false, true)).toBe(true);
expect(Strings.isWordRightToLeft("hello", true, true)).toBe(true);
});
it("should return false for RTL word with reversed direction", () => {
expect(Strings.isWordRightToLeft("مرحبا", true, true)).toBe(false);
expect(Strings.isWordRightToLeft("مرحبا", false, true)).toBe(false);
});
it("should return reverse of languageRTL for undefined word with reversed direction", () => {
expect(Strings.isWordRightToLeft(undefined, false, true)).toBe(true);
expect(Strings.isWordRightToLeft(undefined, true, true)).toBe(false);
});
describe("caching", () => {
@ -349,7 +363,7 @@ describe("string utils", () => {
it("should use cache for repeated calls", () => {
// First call should cache the result (cache miss)
const result1 = Strings.getWordDirection("hello", false);
const result1 = Strings.isWordRightToLeft("hello", false);
expect(result1).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("hello", false);
@ -358,7 +372,7 @@ describe("string utils", () => {
mapSetSpy.mockClear();
// Second call should use cache (cache hit)
const result2 = Strings.getWordDirection("hello", false);
const result2 = Strings.isWordRightToLeft("hello", false);
expect(result2).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
@ -367,7 +381,7 @@ describe("string utils", () => {
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result3 = Strings.getWordDirection("hello", true);
const result3 = Strings.isWordRightToLeft("hello", true);
expect(result3).toBe(false); // Still false because "hello" is LTR regardless of language
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled(); // Should not set again
@ -375,7 +389,7 @@ describe("string utils", () => {
it("should cache based on core word without punctuation", () => {
// First call should cache the result for core "hello"
const result1 = Strings.getWordDirection("hello", false);
const result1 = Strings.isWordRightToLeft("hello", false);
expect(result1).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("hello", false);
@ -383,7 +397,7 @@ describe("string utils", () => {
mapSetSpy.mockClear();
// These should all use the same cache entry since they have the same core
const result2 = Strings.getWordDirection("hello!", false);
const result2 = Strings.isWordRightToLeft("hello!", false);
expect(result2).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
@ -391,7 +405,7 @@ describe("string utils", () => {
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result3 = Strings.getWordDirection("!hello", false);
const result3 = Strings.isWordRightToLeft("!hello", false);
expect(result3).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
@ -399,7 +413,7 @@ describe("string utils", () => {
mapGetSpy.mockClear();
mapSetSpy.mockClear();
const result4 = Strings.getWordDirection("!hello!", false);
const result4 = Strings.isWordRightToLeft("!hello!", false);
expect(result4).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("hello");
expect(mapSetSpy).not.toHaveBeenCalled();
@ -407,7 +421,7 @@ describe("string utils", () => {
it("should handle cache clearing", () => {
// Cache a result
Strings.getWordDirection("test", false);
Strings.isWordRightToLeft("test", false);
expect(mapSetSpy).toHaveBeenCalledWith("test", false);
// Clear cache
@ -419,14 +433,14 @@ describe("string utils", () => {
mapClearSpy.mockClear();
// Should work normally after cache clear (cache miss again)
const result = Strings.getWordDirection("test", false);
const result = Strings.isWordRightToLeft("test", false);
expect(result).toBe(false);
expect(mapSetSpy).toHaveBeenCalledWith("test", false);
});
it("should demonstrate cache miss vs cache hit behavior", () => {
// Test cache miss - first time seeing this word
const result1 = Strings.getWordDirection("unique", false);
const result1 = Strings.isWordRightToLeft("unique", false);
expect(result1).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("unique");
expect(mapSetSpy).toHaveBeenCalledWith("unique", false);
@ -435,7 +449,7 @@ describe("string utils", () => {
mapSetSpy.mockClear();
// Test cache hit - same word again
const result2 = Strings.getWordDirection("unique", false);
const result2 = Strings.isWordRightToLeft("unique", false);
expect(result2).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("unique");
expect(mapSetSpy).not.toHaveBeenCalled(); // No cache set on hit
@ -444,7 +458,7 @@ describe("string utils", () => {
mapSetSpy.mockClear();
// Test cache miss - different word
const result3 = Strings.getWordDirection("different", false);
const result3 = Strings.isWordRightToLeft("different", false);
expect(result3).toBe(false);
expect(mapGetSpy).toHaveBeenCalledWith("different");
expect(mapSetSpy).toHaveBeenCalledWith("different", false);

View file

@ -307,10 +307,6 @@
&.rightToLeftTest {
//flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages
direction: rtl;
.word {
//flex-direction: row-reverse;
direction: rtl;
}
}
&.withLigatures {
.word {
@ -749,10 +745,6 @@
&.rightToLeftTest {
//flex-direction: row-reverse; // no need for hacking 😉, CSS fully support right-to-left languages
direction: rtl;
.word {
//flex-direction: row-reverse;
direction: rtl;
}
}
&.withLigatures {
.word {

View file

@ -326,7 +326,6 @@ async function handleSpace(): Promise<void> {
void TestLogic.addWord();
}
TestUI.updateActiveElement();
void Caret.updatePosition();
const shouldLimitToThreeLines =
Config.mode === "time" ||
@ -344,8 +343,10 @@ async function handleSpace(): Promise<void> {
if ((nextTop ?? 0) > currentTop) {
void TestUI.lineJump(currentTop);
} //end of line wrap
}
}
} //end of line wrap
void Caret.updatePosition();
// enable if i decide that auto tab should also work after a space
// if (

View file

@ -4,8 +4,7 @@
// Constants for padding around the highlights
import * as Misc from "../utils/misc";
import * as JSONData from "../utils/json-data";
import Config from "../config";
import * as TestState from "../test/test-state";
const PADDING_X = 16;
const PADDING_Y = 12;
@ -56,7 +55,6 @@ let isInitialized = false;
let isHoveringChart = false;
let isFirstHighlightSinceInit = true;
let isFirstHighlightSinceClear = true;
let isLanguageRightToLeft = false;
let isInitInProgress = false;
// Highlights .word elements in range [firstWordIndex, lastWordIndex]
@ -104,7 +102,7 @@ export async function highlightWordsInRange(
const newHighlightElementPositions = getHighlightElementPositions(
firstWordIndex,
lastWordIndex,
isLanguageRightToLeft
TestState.isLanguageRightToLeft
);
// For each line...
@ -198,10 +196,6 @@ async function init(): Promise<boolean> {
);
}
// Set isLanguageRTL
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
isLanguageRightToLeft = currentLanguage.rightToLeft ?? false;
RWH_el = $("#resultWordsHistory")[0] as HTMLElement;
RWH_rect = RWH_el.getBoundingClientRect();
wordEls = $(RWH_el).find(".words .word[input]");
@ -309,7 +303,7 @@ async function init(): Promise<boolean> {
// For RTL languages, account for difference between highlightContainer left and RWH_el left
let RTL_offset;
if (isLanguageRightToLeft) {
if (TestState.isLanguageRightToLeft) {
RTL_offset = line.rect.left - RWH_rect.left + PADDING_X;
} else {
RTL_offset = 0;

View file

@ -1,11 +1,10 @@
import * as JSONData from "../utils/json-data";
import Config from "../config";
import * as TestInput from "./test-input";
import * as SlowTimer from "../states/slow-timer";
import * as TestState from "../test/test-state";
import * as TestWords from "./test-words";
import { convertRemToPixels } from "../utils/numbers";
import { splitIntoCharacters, getWordDirection } from "../utils/strings";
import { splitIntoCharacters, isWordRightToLeft } from "../utils/strings";
import { safeNumber } from "@monkeytype/util/numbers";
import { subscribe } from "../observables/config-event";
@ -53,7 +52,6 @@ function getSpaceWidth(wordElement?: HTMLElement): number {
function getTargetPositionLeft(
fullWidthCaret: boolean,
isLanguageRightToLeft: boolean,
activeWordElement: HTMLElement,
currentWordNodeList: NodeListOf<HTMLElement>,
fullWidthCaretWidth: number,
@ -65,9 +63,10 @@ function getTargetPositionLeft(
let result = 0;
// use word-specific direction if available and different from language direction
const isWordRightToLeft = getWordDirection(
const isWordRTL = isWordRightToLeft(
currentWord,
isLanguageRightToLeft
TestState.isLanguageRightToLeft,
TestState.isDirectionReversed
);
if (Config.tapeMode === "off") {
@ -77,7 +76,7 @@ function getTargetPositionLeft(
const lastWordLetter = currentWordNodeList[wordLen - 1];
const lastInputLetter = currentWordNodeList[inputLen - 1];
if (isWordRightToLeft) {
if (isWordRTL) {
if (inputLen <= wordLen && currentLetter) {
// at word beginning in zen mode both lengths are 0, but currentLetter is defined "_"
positionOffsetToWord =
@ -110,13 +109,10 @@ function getTargetPositionLeft(
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
const tapeMargin =
wordsWrapperWidth *
(isWordRightToLeft
? 1 - Config.tapeMargin / 100
: Config.tapeMargin / 100);
(isWordRTL ? 1 - Config.tapeMargin / 100 : Config.tapeMargin / 100);
result =
tapeMargin -
(fullWidthCaret && isWordRightToLeft ? fullWidthCaretWidth : 0);
tapeMargin - (fullWidthCaret && isWordRTL ? fullWidthCaretWidth : 0);
if (Config.tapeMode === "word" && inputLen > 0) {
let currentWordWidth = 0;
@ -131,7 +127,7 @@ function getTargetPositionLeft(
// if current letter has zero width move the caret to previous positive width letter
if ($(currentWordNodeList[inputLen] as Element).outerWidth(true) === 0)
currentWordWidth -= lastPositiveLetterWidth;
if (isWordRightToLeft) currentWordWidth *= -1;
if (isWordRTL) currentWordWidth *= -1;
result += currentWordWidth;
}
}
@ -185,9 +181,6 @@ export async function updatePosition(noAnim = false): Promise<void> {
const lastInputLetter = currentWordNodeList[inputLen - 1];
const lastWordLetter = currentWordNodeList[wordLen - 1];
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRightToLeft = currentLanguage.rightToLeft ?? false;
// in blind mode, and hide extra letters, extra letters have zero offsets
// offsetHeight is the same for all visible letters
// so is offsetTop (for same line letters)
@ -224,7 +217,6 @@ export async function updatePosition(noAnim = false): Promise<void> {
const letterPosLeft = getTargetPositionLeft(
fullWidthCaret,
isLanguageRightToLeft,
activeWordEl,
currentWordNodeList,
letterWidth,

View file

@ -4,12 +4,11 @@ import Config from "../config";
import * as DB from "../db";
import * as SlowTimer from "../states/slow-timer";
import * as Misc from "../utils/misc";
import * as JSONData from "../utils/json-data";
import * as TestState from "./test-state";
import * as ConfigEvent from "../observables/config-event";
import { convertRemToPixels } from "../utils/numbers";
import { getActiveFunboxes } from "./funbox/list";
import { getWordDirection } from "../utils/strings";
import { isWordRightToLeft } from "../utils/strings";
type Settings = {
wpm: number;
@ -51,22 +50,18 @@ async function resetCaretPosition(): Promise<void> {
if (firstLetter === undefined || firstLetterHeight === undefined) return;
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRightToLeft = currentLanguage.rightToLeft;
const currentWord = TestWords.words.get(settings?.currentWordIndex ?? 0);
const isWordRightToLeft = getWordDirection(
const isWordRTL = isWordRightToLeft(
currentWord,
isLanguageRightToLeft ?? false
TestState.isLanguageRightToLeft,
TestState.isDirectionReversed
);
caret.stop(true, true).animate(
{
top: firstLetter.offsetTop - firstLetterHeight / 4,
left:
firstLetter.offsetLeft +
(isWordRightToLeft ? firstLetter.offsetWidth : 0),
left: firstLetter.offsetLeft + (isWordRTL ? firstLetter.offsetWidth : 0),
},
0,
"linear"
@ -238,17 +233,14 @@ export async function update(expectedStepEnd: number): Promise<void> {
);
}
const currentLanguage = await JSONData.getCurrentLanguage(
Config.language
);
const isLanguageRightToLeft = currentLanguage.rightToLeft;
const currentWord = TestWords.words.get(settings.currentWordIndex);
const isWordRightToLeft = getWordDirection(
const isWordRTL = isWordRightToLeft(
currentWord,
isLanguageRightToLeft ?? false
TestState.isLanguageRightToLeft,
TestState.isDirectionReversed
);
newTop =
word.offsetTop +
currentLetter.offsetTop -
@ -258,13 +250,13 @@ export async function update(expectedStepEnd: number): Promise<void> {
word.offsetLeft +
currentLetter.offsetLeft -
caretWidth / 2 +
(isWordRightToLeft ? currentLetterWidth : 0);
(isWordRTL ? currentLetterWidth : 0);
} else {
newLeft =
word.offsetLeft +
currentLetter.offsetLeft -
caretWidth / 2 +
(isWordRightToLeft ? 0 : currentLetterWidth);
(isWordRTL ? 0 : currentLetterWidth);
}
caret.removeClass("hidden");
} catch (e) {

View file

@ -70,6 +70,7 @@ import {
getActiveFunboxes,
getActiveFunboxesWithFunction,
isFunboxActive,
isFunboxActiveWithProperty,
} from "./funbox/list";
import { getFunbox } from "@monkeytype/funbox";
import * as CompositionState from "../states/composition";
@ -408,7 +409,7 @@ let lastInitError: Error | null = null;
let rememberLazyMode: boolean;
let testReinitCount = 0;
export async function init(): Promise<boolean> {
async function init(): Promise<boolean> {
console.debug("Initializing test");
testReinitCount++;
if (testReinitCount > 3) {
@ -571,6 +572,13 @@ export async function init(): Promise<boolean> {
Funbox.toggleScript(TestWords.words.getCurrent());
TestUI.setRightToLeft(language.rightToLeft ?? false);
TestUI.setLigatures(language.ligatures ?? false);
const isLanguageRTL = language.rightToLeft ?? false;
TestState.setIsLanguageRightToLeft(isLanguageRTL);
TestState.setIsDirectionReversed(
isFunboxActiveWithProperty("reverseDirection")
);
TestUI.showWords();
console.debug("Test initialized with words", generatedWords);
console.debug(

View file

@ -10,6 +10,8 @@ export let selectedQuoteId = 1;
export let activeWordIndex = 0;
export let testInitSuccess = true;
export let lineScrollDistance = 0;
export let isLanguageRightToLeft = false;
export let isDirectionReversed = false;
export function setRepeated(tf: boolean): void {
isRepeated = tf;
@ -58,3 +60,11 @@ export function setTestInitSuccess(tf: boolean): void {
export function setLineScrollDistance(val: number): void {
lineScrollDistance = val;
}
export function setIsLanguageRightToLeft(rtl: boolean): void {
isLanguageRightToLeft = rtl;
}
export function setIsDirectionReversed(val: boolean): void {
isDirectionReversed = val;
}

View file

@ -229,7 +229,7 @@ export function updateActiveElement(
activeWordTop = newActiveWord.offsetTop;
void updateWordsInputPosition();
updateWordsInputPosition();
if (!initial && Config.tapeMode !== "off") {
void scrollTape();
@ -272,8 +272,11 @@ async function joinOverlappingHints(
activeWordLetters: NodeListOf<Element>,
hintElements: HTMLCollection
): Promise<void> {
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLanguage.rightToLeft;
const isWordRightToLeft = Strings.isWordRightToLeft(
TestWords.words.getCurrent(),
TestState.isLanguageRightToLeft,
TestState.isDirectionReversed
);
let previousBlocksAdjacent = false;
let currentHintBlock = 0;
@ -305,8 +308,8 @@ async function joinOverlappingHints(
const sameTop = block1Letter1.offsetTop === block2Letter1.offsetTop;
const leftBlock = isLanguageRTL ? hintBlock2 : hintBlock1;
const rightBlock = isLanguageRTL ? hintBlock1 : hintBlock2;
const leftBlock = isWordRightToLeft ? hintBlock2 : hintBlock1;
const rightBlock = isWordRightToLeft ? hintBlock1 : hintBlock2;
// block edge is offset half its width because of transform: translate(-50%)
const leftBlockEnds = leftBlock.offsetLeft + leftBlock.offsetWidth / 2;
@ -321,7 +324,7 @@ async function joinOverlappingHints(
const block1Letter1Pos =
block1Letter1.offsetLeft +
(isLanguageRTL ? block1Letter1.offsetWidth : 0);
(isWordRightToLeft ? block1Letter1.offsetWidth : 0);
const bothBlocksLettersWidthHalved =
hintBlock2.offsetLeft - hintBlock1.offsetLeft;
hintBlock1.style.left =
@ -510,15 +513,16 @@ export function appendEmptyWordElement(
);
}
let updateWordsInputPositionAnimationFrameId: null | number = null;
export async function updateWordsInputPosition(): Promise<void> {
export function updateWordsInputPosition(): void {
if (updateWordsInputPositionAnimationFrameId !== null) {
cancelAnimationFrame(updateWordsInputPositionAnimationFrameId);
}
updateWordsInputPositionAnimationFrameId = requestAnimationFrame(async () => {
updateWordsInputPositionAnimationFrameId = requestAnimationFrame(() => {
updateWordsInputPositionAnimationFrameId = null;
if (ActivePage.get() !== "test") return;
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLanguage.rightToLeft;
const isTestRightToLeft = TestState.isDirectionReversed
? !TestState.isLanguageRightToLeft
: TestState.isLanguageRightToLeft;
const el = document.querySelector<HTMLElement>("#wordsInput");
@ -549,7 +553,7 @@ export async function updateWordsInputPosition(): Promise<void> {
el.style.top = targetTop + "px";
if (activeWord.offsetWidth < letterHeight && isLanguageRTL) {
if (activeWord.offsetWidth < letterHeight && isTestRightToLeft) {
el.style.left = activeWord.offsetLeft - letterHeight + "px";
} else {
el.style.left = Math.max(0, activeWord.offsetLeft) + "px";
@ -913,8 +917,9 @@ export async function scrollTape(
await centeringActiveLine;
const currentLang = await JSONData.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLang.rightToLeft;
const isTestRightToLeft = TestState.isDirectionReversed
? !TestState.isLanguageRightToLeft
: TestState.isLanguageRightToLeft;
const wordsWrapperWidth = (
document.querySelector("#wordsWrapper") as HTMLElement
@ -988,8 +993,8 @@ export async function scrollTape(
const forWordLeft = Math.floor(child.offsetLeft);
const forWordWidth = Math.floor(child.offsetWidth);
if (
(!isLanguageRTL && forWordLeft < 0 - forWordWidth) ||
(isLanguageRTL && forWordLeft > wordsWrapperWidth)
(!isTestRightToLeft && forWordLeft < 0 - forWordWidth) ||
(isTestRightToLeft && forWordLeft > wordsWrapperWidth)
) {
toRemove.push(child);
widthRemoved += wordOuterWidth;
@ -1035,7 +1040,7 @@ export async function scrollTape(
currentLineIndent - (widthRemovedFromLine[i] ?? 0)
}px`;
}
if (isLanguageRTL) widthRemoved *= -1;
if (isTestRightToLeft) widthRemoved *= -1;
const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0;
wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`;
}
@ -1068,7 +1073,7 @@ export async function scrollTape(
wordsWrapperWidth * (Config.tapeMargin / 100) -
wordsWidthBeforeActive -
currentWordWidth;
if (isLanguageRTL) newMargin = wordRightMargin - newMargin;
if (isTestRightToLeft) newMargin = wordRightMargin - newMargin;
const jqWords = $(wordsEl);
if (Config.smoothLineScroll) {

View file

@ -106,7 +106,7 @@ const debouncedEvent = debounce(250, () => {
void TestUI.updateHintsPositionDebounced();
}
setTimeout(() => {
void TestUI.updateWordsInputPosition();
TestUI.updateWordsInputPosition();
TestUI.focusWords();
}, 250);
}

View file

@ -236,26 +236,29 @@ export function clearWordDirectionCache(): void {
wordDirectionCache.clear();
}
export function getWordDirection(
export function isWordRightToLeft(
word: string | undefined,
languageRTL: boolean
languageRTL: boolean,
reverseDirection?: boolean
): boolean {
if (word === undefined || word.length === 0) return languageRTL;
if (word === undefined || word.length === 0) {
return reverseDirection ? !languageRTL : languageRTL;
}
// Strip leading/trailing punctuation and whitespace so attached opposite-direction
// punctuation like "word؟" or "،word" doesn't flip the direction detection
// and if only punctuation/symbols/whitespace, use main language direction
const core = word.replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, "");
if (core.length === 0) return languageRTL;
if (core.length === 0) return reverseDirection ? !languageRTL : languageRTL;
// cache by core to handle variants like "word" vs "word؟"
const cached = wordDirectionCache.get(core);
if (cached !== undefined) return cached;
if (cached !== undefined) return reverseDirection ? !cached : cached;
const result = hasRTLCharacters(core);
wordDirectionCache.set(core, result);
return result;
return reverseDirection ? !result : result;
}
// Export testing utilities for unit tests

View file

@ -3,5 +3,9 @@
}
#words.rightToLeftTest {
direction: rtl;
direction: ltr;
}
#words.withLigatures .word {
unicode-bidi: bidi-override;
}

View file

@ -404,9 +404,9 @@ const list: Record<FunboxName, FunboxMetadata> = {
name: "backwards",
properties: [
"hasCssFile",
"noLigatures",
"conflictsWithSymmetricChars",
"wordOrder:reverse",
"reverseDirection",
],
canGetPb: true,
frontendFunctions: ["alterText"],

View file

@ -21,6 +21,7 @@ export type FunboxProperty =
| "noLigatures"
| `toPush:${number}`
| "wordOrder:reverse"
| "reverseDirection"
| "ignoreReducedMotion";
type FunboxCSSModification = "typingTest" | "words" | "body" | "main";