diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index 062eefd46..78ee18de2 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -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); diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index edf677763..72631e366 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -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 { diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index c29dd3c28..670c571f0 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -326,7 +326,6 @@ async function handleSpace(): Promise { void TestLogic.addWord(); } TestUI.updateActiveElement(); - void Caret.updatePosition(); const shouldLimitToThreeLines = Config.mode === "time" || @@ -344,8 +343,10 @@ async function handleSpace(): Promise { 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 ( diff --git a/frontend/src/ts/elements/result-word-highlight.ts b/frontend/src/ts/elements/result-word-highlight.ts index 4d771db8c..bfd4505b2 100644 --- a/frontend/src/ts/elements/result-word-highlight.ts +++ b/frontend/src/ts/elements/result-word-highlight.ts @@ -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 { ); } - // 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 { // 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; diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index a8d0aab1b..ce54653cf 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -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, 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 { 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 { const letterPosLeft = getTargetPositionLeft( fullWidthCaret, - isLanguageRightToLeft, activeWordEl, currentWordNodeList, letterWidth, diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 77c8762ec..a3f367c66 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -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 { 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 { ); } - 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 { 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) { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index deddad44a..083028946 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -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 { +async function init(): Promise { console.debug("Initializing test"); testReinitCount++; if (testReinitCount > 3) { @@ -571,6 +572,13 @@ export async function init(): Promise { 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( diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index c695b3e11..97a066578 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -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; +} diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 22ea7d322..efa1ffa42 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -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, hintElements: HTMLCollection ): Promise { - 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 { +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("#wordsInput"); @@ -549,7 +553,7 @@ export async function updateWordsInputPosition(): Promise { 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) { diff --git a/frontend/src/ts/ui.ts b/frontend/src/ts/ui.ts index 3afadcec5..362d5e036 100644 --- a/frontend/src/ts/ui.ts +++ b/frontend/src/ts/ui.ts @@ -106,7 +106,7 @@ const debouncedEvent = debounce(250, () => { void TestUI.updateHintsPositionDebounced(); } setTimeout(() => { - void TestUI.updateWordsInputPosition(); + TestUI.updateWordsInputPosition(); TestUI.focusWords(); }, 250); } diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 264d54560..0786bd3e4 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -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 diff --git a/frontend/static/funbox/backwards.css b/frontend/static/funbox/backwards.css index 2485d2af3..10d73f96a 100644 --- a/frontend/static/funbox/backwards.css +++ b/frontend/static/funbox/backwards.css @@ -3,5 +3,9 @@ } #words.rightToLeftTest { - direction: rtl; + direction: ltr; +} + +#words.withLigatures .word { + unicode-bidi: bidi-override; } diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index eacce0e4b..2f49d3728 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -404,9 +404,9 @@ const list: Record = { name: "backwards", properties: [ "hasCssFile", - "noLigatures", "conflictsWithSymmetricChars", "wordOrder:reverse", + "reverseDirection", ], canGetPb: true, frontendFunctions: ["alterText"], diff --git a/packages/funbox/src/types.ts b/packages/funbox/src/types.ts index 0e0e99d30..104174ff3 100644 --- a/packages/funbox/src/types.ts +++ b/packages/funbox/src/types.ts @@ -21,6 +21,7 @@ export type FunboxProperty = | "noLigatures" | `toPush:${number}` | "wordOrder:reverse" + | "reverseDirection" | "ignoreReducedMotion"; type FunboxCSSModification = "typingTest" | "words" | "body" | "main";