diff --git a/frontend/__tests__/input/helpers/fail-or-finish.spec.ts b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts new file mode 100644 index 000000000000..792592c8704e --- /dev/null +++ b/frontend/__tests__/input/helpers/fail-or-finish.spec.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { + checkIfFailedDueToMinBurst, + checkIfFailedDueToDifficulty, + checkIfFinished, +} from "../../../src/ts/input/helpers/fail-or-finish"; +import { __testing } from "../../../src/ts/config"; +import * as Misc from "../../../src/ts/utils/misc"; +import * as TestLogic from "../../../src/ts/test/test-logic"; +import * as Strings from "../../../src/ts/utils/strings"; + +const { replaceConfig } = __testing; + +vi.mock("../../../src/ts/utils/misc", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../src/ts/utils/misc") + >(); + return { + ...actual, + whorf: vi.fn(), + }; +}); + +vi.mock("../../../src/ts/test/test-logic", () => ({ + areAllTestWordsGenerated: vi.fn(), +})); + +vi.mock("../../../src/ts/utils/strings", () => ({ + isSpace: vi.fn(), +})); + +describe("checkIfFailedDueToMinBurst", () => { + beforeEach(() => { + vi.clearAllMocks(); + replaceConfig({ + minBurst: "off", + mode: "time", + minBurstCustomSpeed: 100, + }); + (Misc.whorf as any).mockReturnValue(0); + (TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true); + }); + + afterAll(() => { + replaceConfig({}); + }); + + it.each([ + { + desc: "returns false if minBurst is off", + config: { minBurst: "off" }, + lastBurst: 50, + expected: false, + }, + { + desc: "returns false if lastBurst is null", + config: { minBurst: "fixed" }, + lastBurst: null, + expected: false, + }, + { + desc: "returns true if fixed burst is too slow", + config: { minBurst: "fixed", minBurstCustomSpeed: 100 }, + lastBurst: 99, + expected: true, + }, + { + desc: "returns false if fixed burst is fast enough", + config: { minBurst: "fixed", minBurstCustomSpeed: 100 }, + lastBurst: 100, + expected: false, + }, + { + desc: "returns true if flex burst is too slow", + config: { minBurst: "flex", minBurstCustomSpeed: 100 }, + lastBurst: 49, + whorfRet: 50, + expected: true, + }, + { + desc: "returns false if flex burst is fast enough", + config: { minBurst: "flex", minBurstCustomSpeed: 100 }, + lastBurst: 50, + whorfRet: 50, + expected: false, + }, + ])("$desc", ({ config, lastBurst, whorfRet, expected }) => { + replaceConfig(config as any); + if (whorfRet !== undefined) { + (Misc.whorf as any).mockReturnValue(whorfRet); + } + + const result = checkIfFailedDueToMinBurst({ + testInputWithData: "test", + currentWord: "test", + lastBurst, + }); + + expect(result).toBe(expected); + }); + + it("uses correct length for whorf calculation in zen mode", () => { + replaceConfig({ minBurst: "flex", mode: "zen", minBurstCustomSpeed: 100 }); + checkIfFailedDueToMinBurst({ + testInputWithData: "zeninput", + currentWord: "ignored", + lastBurst: 50, + }); + expect(Misc.whorf).toHaveBeenCalledWith(100, 8); + }); + + it("uses correct length for whorf calculation in normal mode", () => { + replaceConfig({ minBurst: "flex", mode: "time", minBurstCustomSpeed: 100 }); + checkIfFailedDueToMinBurst({ + testInputWithData: "input", + currentWord: "target", + lastBurst: 50, + }); + expect(Misc.whorf).toHaveBeenCalledWith(100, 6); + }); +}); + +describe("checkIfFailedDueToDifficulty", () => { + beforeEach(() => { + replaceConfig({ + mode: "time", + difficulty: "normal", + }); + }); + + afterAll(() => { + replaceConfig({}); + }); + + it.each([ + { + desc: "zen mode, master - never fails", + config: { mode: "zen", difficulty: "master" }, + correct: false, + spaceOrNewline: true, + input: "hello", + expected: false, + }, + { + desc: "zen mode - never fails", + config: { mode: "zen", difficulty: "normal" }, + correct: false, + spaceOrNewline: true, + input: "hello", + expected: false, + }, + // + { + desc: "normal typing incorrect- never fails", + config: { difficulty: "normal" }, + correct: false, + spaceOrNewline: false, + input: "hello", + expected: false, + }, + { + desc: "normal typing space incorrect - never fails", + config: { difficulty: "normal" }, + correct: false, + spaceOrNewline: true, + input: "hello", + expected: false, + }, + { + desc: "normal typing correct - never fails", + config: { difficulty: "normal" }, + correct: true, + spaceOrNewline: false, + input: "hello", + expected: false, + }, + { + desc: "normal typing space correct - never fails", + config: { difficulty: "normal" }, + correct: true, + spaceOrNewline: true, + input: "hello", + expected: false, + }, + // + { + desc: "expert - fail if incorrect space", + config: { difficulty: "expert" }, + correct: false, + spaceOrNewline: true, + input: "he", + expected: true, + }, + { + desc: "expert - dont fail if space is the first character", + config: { difficulty: "expert" }, + correct: false, + spaceOrNewline: true, + input: " ", + expected: false, + }, + { + desc: "expert: - dont fail if just typing", + config: { difficulty: "expert" }, + correct: false, + spaceOrNewline: false, + input: "h", + expected: false, + }, + { + desc: "expert: - dont fail if just typing", + config: { difficulty: "expert" }, + correct: true, + spaceOrNewline: false, + input: "h", + expected: false, + }, + // + { + desc: "master - fail if incorrect char", + config: { difficulty: "master" }, + correct: false, + spaceOrNewline: false, + input: "h", + expected: true, + }, + { + desc: "master - fail if incorrect first space", + config: { difficulty: "master" }, + correct: true, + spaceOrNewline: true, + input: " ", + expected: false, + }, + { + desc: "master - dont fail if correct char", + config: { difficulty: "master" }, + correct: true, + spaceOrNewline: false, + input: "a", + expected: false, + }, + { + desc: "master - dont fail if correct space", + config: { difficulty: "master" }, + correct: true, + spaceOrNewline: true, + input: " ", + expected: false, + }, + ])("$desc", ({ config, correct, spaceOrNewline, input, expected }) => { + replaceConfig(config as any); + const result = checkIfFailedDueToDifficulty({ + testInputWithData: input, + correct, + spaceOrNewline, + }); + expect(result).toBe(expected); + }); +}); + +describe("checkIfFinished", () => { + beforeEach(() => { + vi.clearAllMocks(); + replaceConfig({ + quickEnd: false, + stopOnError: "off", + }); + (Strings.isSpace as any).mockReturnValue(false); + (TestLogic.areAllTestWordsGenerated as any).mockReturnValue(true); + }); + + afterAll(() => { + replaceConfig({}); + }); + + it.each([ + { + desc: "false if not all words typed", + allWordsTyped: false, + testInputWithData: "word", + currentWord: "word", + expected: false, + }, + { + desc: "false if not all words generated, but on the last word", + allWordsGenerated: false, + allWordsTyped: true, + testInputWithData: "word", + currentWord: "word", + expected: false, + }, + { + desc: "true if last word is correct", + allWordsTyped: true, + testInputWithData: "word", + currentWord: "word", + expected: true, + }, + { + desc: "true if quickEnd enabled and lengths match", + allWordsTyped: true, + testInputWithData: "asdf", + currentWord: "word", + config: { quickEnd: true }, + expected: true, + }, + { + desc: "false if quickEnd disabled and lengths match", + allWordsTyped: true, + testInputWithData: "asdf", + currentWord: "word", + config: { quickEnd: false }, + expected: false, + }, + { + desc: "true if space on the last word", + allWordsTyped: true, + testInputWithData: "wo ", + currentWord: "word", + shouldGoToNextWord: true, + expected: true, + }, + { + desc: "false if still typing, quickend disabled", + allWordsTyped: true, + testInputWithData: "wordwordword", + currentWord: "word", + expected: false, + }, + ] as { + desc: string; + allWordsTyped: boolean; + allWordsGenerated?: boolean; + shouldGoToNextWord: boolean; + testInputWithData: string; + currentWord: string; + config?: Record; + isSpace?: boolean; + expected: boolean; + }[])( + "$desc", + ({ + allWordsTyped, + allWordsGenerated, + shouldGoToNextWord, + testInputWithData, + currentWord, + config, + expected, + }) => { + if (config) replaceConfig(config as any); + + const result = checkIfFinished({ + shouldGoToNextWord, + testInputWithData, + currentWord, + allWordsTyped, + allWordsGenerated: allWordsGenerated ?? true, + }); + + expect(result).toBe(expected); + } + ); +}); diff --git a/frontend/__tests__/input/helpers/validation.spec.ts b/frontend/__tests__/input/helpers/validation.spec.ts new file mode 100644 index 000000000000..51b28969eae6 --- /dev/null +++ b/frontend/__tests__/input/helpers/validation.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { + isCharCorrect, + shouldInsertSpaceCharacter, +} from "../../../src/ts/input/helpers/validation"; +import { __testing } from "../../../src/ts/config"; +import * as FunboxList from "../../../src/ts/test/funbox/list"; +import * as Strings from "../../../src/ts/utils/strings"; + +const { replaceConfig } = __testing; + +// Mock dependencies +vi.mock("../../../src/ts/test/funbox/list", () => ({ + findSingleActiveFunboxWithFunction: vi.fn(), +})); + +vi.mock("../../../src/ts/utils/strings", () => ({ + areCharactersVisuallyEqual: vi.fn(), + isSpace: vi.fn(), +})); + +describe("isCharCorrect", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset Config defaults + replaceConfig({ + mode: "words", + language: "english", + stopOnError: "off", + difficulty: "normal", + strictSpace: false, + }); + (FunboxList.findSingleActiveFunboxWithFunction as any).mockReturnValue( + null + ); + (Strings.areCharactersVisuallyEqual as any).mockReturnValue(false); + (Strings.isSpace as any).mockReturnValue(false); + }); + + afterAll(() => { + replaceConfig({}); + }); + + describe("Zen Mode", () => { + it("always returns true", () => { + replaceConfig({ mode: "zen" }); + expect( + isCharCorrect({ + data: "a", + inputValue: "test", + targetWord: "word", + correctShiftUsed: true, + }) + ).toBe(true); + }); + }); + + describe("Shift Key", () => { + it("returns false if correct shift was not used", () => { + expect( + isCharCorrect({ + data: "A", + inputValue: "test", + targetWord: "testA", + correctShiftUsed: false, + }) + ).toBe(false); + }); + }); + + describe("Space Handling", () => { + it.each([ + ["returns true at the end of a correct word", " ", "word", "word", true], + [ + "returns false at the end of an incorrect word", + " ", + "worx", + "word", + false, + ], + ["returns false in the middle of a word", " ", "wor", "word", false], + ["returns false at the start of a word", " ", "", "word", false], + [ + "returns false when longer than a word", + " ", + "wordwordword", + "word", + false, + ], + ])("%s", (_desc, char, input, word, expected) => { + expect( + isCharCorrect({ + data: char, + inputValue: input, + targetWord: word, + correctShiftUsed: true, + }) + ).toBe(expected); + }); + }); + + describe("Standard Matching", () => { + it.each([ + ["a", "te", "tea", true], + ["b", "te", "tea", false], + ["x", "tea", "tea", false], + ])( + "char '%s' for input '%s' (current word '%s') -> %s", + (char, input, word, expected) => { + expect( + isCharCorrect({ + data: char, + inputValue: input, + targetWord: word, + correctShiftUsed: true, + }) + ).toBe(expected); + } + ); + }); + + it("throws error if data is undefined", () => { + expect(() => + isCharCorrect({ + data: undefined as any, + inputValue: "val", + targetWord: "word", + correctShiftUsed: true, + }) + ).toThrow(); + }); +}); + +describe("shouldInsertSpaceCharacter", () => { + beforeEach(() => { + (Strings.isSpace as any).mockReturnValue(true); + replaceConfig({ + mode: "time", + stopOnError: "off", + strictSpace: false, + difficulty: "normal", + }); + }); + + afterAll(() => { + replaceConfig({}); + }); + + it("returns null if data is not a space", () => { + (Strings.isSpace as any).mockReturnValue(false); + expect( + shouldInsertSpaceCharacter({ + data: "a", + inputValue: "test", + targetWord: "test", + }) + ).toBe(null); + }); + + it("returns false in zen mode", () => { + replaceConfig({ mode: "zen" }); + expect( + shouldInsertSpaceCharacter({ + data: " ", + inputValue: "test", + targetWord: "test", + }) + ).toBe(false); + }); + + describe("Logic Checks", () => { + it.each([ + // Standard behavior (submit word) + { + desc: "submit correct word", + inputValue: "hello", + targetWord: "hello", + config: { + stopOnError: "off", + strictSpace: false, + difficulty: "normal", + }, + expected: false, + }, + { + desc: "submit incorrect word (stopOnError off)", + inputValue: "hel", + targetWord: "hello", + config: { + stopOnError: "off", + strictSpace: false, + difficulty: "normal", + }, + expected: false, + }, + // Stop on error + { + desc: "insert space if incorrect (stopOnError letter)", + inputValue: "hel", + targetWord: "hello", + config: { + stopOnError: "letter", + strictSpace: false, + difficulty: "normal", + }, + expected: true, + }, + { + desc: "insert space if incorrect (stopOnError word)", + inputValue: "hel", + targetWord: "hello", + config: { + stopOnError: "word", + strictSpace: false, + difficulty: "normal", + }, + expected: true, + }, + { + desc: "submit if correct (stopOnError letter)", + inputValue: "hello", + targetWord: "hello", + config: { + stopOnError: "letter", + strictSpace: false, + difficulty: "normal", + }, + expected: false, + }, + // Strict space / Difficulty + { + desc: "insert space if empty input (strictSpace on)", + inputValue: "", + targetWord: "hello", + config: { + stopOnError: "off", + strictSpace: true, + difficulty: "normal", + }, + expected: true, + }, + { + desc: "insert space if empty input (difficulty not normal - expert or master)", + inputValue: "", + targetWord: "hello", + config: { + stopOnError: "off", + strictSpace: false, + difficulty: "expert", + }, + expected: true, + }, + { + desc: "submit if not empty input (strictSpace on)", + inputValue: "h", + targetWord: "hello", + config: { + stopOnError: "off", + strictSpace: true, + difficulty: "normal", + }, + expected: false, + }, + ])("$desc", ({ inputValue, targetWord, config, expected }) => { + replaceConfig(config as any); + expect( + shouldInsertSpaceCharacter({ + data: " ", + inputValue, + targetWord, + }) + ).toBe(expected); + }); + }); +}); diff --git a/frontend/__tests__/setup-tests.ts b/frontend/__tests__/setup-tests.ts index c7bf9541fb22..ce5a30cf562a 100644 --- a/frontend/__tests__/setup-tests.ts +++ b/frontend/__tests__/setup-tests.ts @@ -17,3 +17,7 @@ vi.mock("../src/ts/firebase", () => ({ Auth: undefined, isAuthenticated: () => false, })); + +const input = document.createElement("input"); +input.id = "wordsInput"; +document.body.appendChild(input); diff --git a/frontend/__tests__/utils/strings.spec.ts b/frontend/__tests__/utils/strings.spec.ts index a84029d13a1a..3122c746e0bd 100644 --- a/frontend/__tests__/utils/strings.spec.ts +++ b/frontend/__tests__/utils/strings.spec.ts @@ -466,6 +466,49 @@ describe("string utils", () => { }); }); + describe("isSpace", () => { + it.each([ + // Should return true for directly typable spaces + [" ", 0x0020, "regular space", true], + ["\u2002", 0x2002, "en space", true], + ["\u2003", 0x2003, "em space", true], + ["\u2009", 0x2009, "thin space", true], + [" ", 0x3000, "ideographic space", true], + + // Should return false for other characters + ["\t", 0x0009, "tab", false], + ["\u00A0", 0x00a0, "non-breaking space", false], + ["\u2007", 0x2007, "figure space", false], + ["\u2008", 0x2008, "punctuation space", false], + ["\u200A", 0x200a, "hair space", false], + ["​", 0x200b, "zero-width space", false], + ["a", 0x0061, "letter a", false], + ["A", 0x0041, "letter A", false], + ["1", 0x0031, "digit 1", false], + ["!", 0x0021, "exclamation mark", false], + ["\n", 0x000a, "newline", false], + ["\r", 0x000d, "carriage return", false], + + // Edge cases + ["", null, "empty string", false], + [" ", null, "two spaces", false], + ["ab", null, "two letters", false], + ])( + "should return %s for %s (U+%s - %s)", + ( + char: string, + expectedCodePoint: number | null, + description: string, + expected: boolean + ) => { + if (expectedCodePoint !== null && char.length === 1) { + expect(char.codePointAt(0)).toBe(expectedCodePoint); + } + expect(Strings.isSpace(char)).toBe(expected); + } + ); + }); + describe("areCharactersVisuallyEqual", () => { it("should return true for identical characters", () => { expect(Strings.areCharactersVisuallyEqual("a", "a")).toBe(true); @@ -504,5 +547,44 @@ describe("string utils", () => { expect(Strings.areCharactersVisuallyEqual("-", "'")).toBe(false); expect(Strings.areCharactersVisuallyEqual(",", '"')).toBe(false); }); + + describe("should check russian specific equivalences", () => { + it.each([ + { + desc: "е and ё are equivalent", + char1: "е", + char2: "ё", + expected: true, + }, + { + desc: "e and ё are equivalent", + char1: "e", + char2: "ё", + expected: true, + }, + { + desc: "е and e are equivalent", + char1: "е", + char2: "e", + expected: true, + }, + { + desc: "non-equivalent characters return false", + char1: "а", + char2: "б", + expected: false, + }, + { + desc: "non-equivalent characters return false (2)", + char1: "a", + char2: "б", + expected: false, + }, + ])("$desc", ({ char1, char2, expected }) => { + expect( + Strings.areCharactersVisuallyEqual(char1, char2, "russian") + ).toBe(expected); + }); + }); }); }); diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index 83ba8d4c5539..777d1f7dadc7 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -136,10 +136,9 @@
- + > - +
diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index ca9b8d94abe2..49587b8b2c01 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -353,6 +353,10 @@ & .hints hint { display: none; } + + &.error { + border-bottom: 2px solid transparent; + } } // &.highlight-word { // .word.typed.error, @@ -385,6 +389,9 @@ --untyped-letter-color: var(--sub-color); --incorrect-letter-color: var(--colorful-error-color); --extra-letter-color: var(--colorful-error-extra-color); + &.blind .word.error { + border-bottom: 2px solid transparent; + } } &.flipped.colorfulMode { @@ -593,6 +600,8 @@ pointer-events: none; border-radius: 0; caret-color: transparent; + resize: none; + overflow: hidden; } #capsWarning { @@ -1201,19 +1210,16 @@ padding: 1em 2em; } -#koInputVisualContainer { - position: relative; - padding-top: 1rem; - width: -moz-min-content; - width: min-content; - height: 3rem; - margin: 0 auto; - - font-weight: bold; - font-size: 1.5rem; - color: var(--sub-color); - cursor: default; +#compositionDisplay { + font-size: 0.5em; + display: flex; + margin: 1rem auto 0px; + padding: 1rem 2rem; + height: 1.25em; + box-sizing: content-box; user-select: none; + cursor: default; + color: var(--sub-color); &.blurred { opacity: 0.25; diff --git a/frontend/src/ts/commandline/commandline.ts b/frontend/src/ts/commandline/commandline.ts index 86d713949c4f..888cfb84f4d3 100644 --- a/frontend/src/ts/commandline/commandline.ts +++ b/frontend/src/ts/commandline/commandline.ts @@ -8,7 +8,6 @@ import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import * as Notifications from "../elements/notifications"; import * as OutOfFocus from "../test/out-of-focus"; import * as ActivePage from "../states/active-page"; -import { focusWords } from "../test/test-ui"; import * as Loader from "../elements/loader"; import { Command, CommandsSubgroup, CommandWithValidation } from "./types"; import { areSortedArraysEqual, areUnsortedArraysEqual } from "../utils/arrays"; @@ -19,6 +18,7 @@ import { createInputEventHandler, ValidationResult, } from "../elements/input-validation"; +import { isInputElementFocused } from "../input/input-element"; type CommandlineMode = "search" | "input"; type InputModeParams = { @@ -63,8 +63,7 @@ function removeCommandlineBackground(): void { function addCommandlineBackground(): void { $("#commandLine").removeClass("noBackground"); - const isWordsFocused = $("#wordsInput").is(":focus"); - if (Config.showOutOfFocusWarning && !isWordsFocused) { + if (Config.showOutOfFocusWarning && !isInputElementFocused()) { OutOfFocus.show(); } } @@ -175,21 +174,13 @@ export function show( function hide(clearModalChain = false): void { clearFontPreview(); void ThemeController.clearPreview(); - if (ActivePage.get() === "test") { - focusWords(); - } isAnimating = true; void modal.hide({ clearModalChain, afterAnimation: async () => { hideWarning(); addCommandlineBackground(); - if (ActivePage.get() === "test") { - const isWordsFocused = $("#wordsInput").is(":focus"); - if (ActivePage.get() === "test" && !isWordsFocused) { - focusWords(); - } - } else { + if (ActivePage.get() !== "test") { (document.activeElement as HTMLElement | undefined)?.blur(); } isAnimating = false; diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index bd4d2931425e..1dbe01c36674 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -332,6 +332,29 @@ export const commands: CommandsSubgroup = { ], }, }, + { + id: "fixSkillIssue", + display: "Fix skill issue", + icon: "fa-wrench", + visible: false, + exec: async (): Promise => { + // window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); + (document.querySelector("body") as HTMLElement).innerHTML = ` +
+

Fixing skill issue...

+ +
+ `; + setTimeout(() => { + document + .querySelector(".centerbox") + ?.insertAdjacentHTML( + "beforeend", + `

If your skill issue is not fixed yet, please wait a bit longer...

` + ); + }, 5000); + }, + }, { id: "joinDiscord", display: "Join the Discord server", diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index cf732b09f53a..de1054e21a6b 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -921,7 +921,11 @@ export default config; export const __testing = { configMetadata, replaceConfig: (setConfig: Partial): void => { - config = { ...getDefaultConfig(), ...setConfig }; + const newConfig = { ...getDefaultConfig(), ...setConfig }; + for (const key of Object.keys(config)) { + Reflect.deleteProperty(config, key); + } + Object.assign(config, newConfig); configToSend = {} as Config; }, getConfig: () => config, diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts deleted file mode 100644 index 32ef91efc72d..000000000000 --- a/frontend/src/ts/controllers/input-controller.ts +++ /dev/null @@ -1,1444 +0,0 @@ -import * as TestLogic from "../test/test-logic"; -import * as TestUI from "../test/test-ui"; -import * as TestStats from "../test/test-stats"; -import * as Monkey from "../test/monkey"; -import Config from "../config"; -import * as Misc from "../utils/misc"; -import * as JSONData from "../utils/json-data"; -import * as Numbers from "@monkeytype/util/numbers"; -import * as LiveAcc from "../test/live-acc"; -import * as LiveBurst from "../test/live-burst"; -import * as Funbox from "../test/funbox/funbox"; -import * as Sound from "./sound-controller"; -import * as Caret from "../test/caret"; -import * as ManualRestart from "../test/manual-restart-tracker"; -import * as CustomText from "../test/custom-text"; -import * as LayoutEmulator from "../test/layout-emulator"; -import * as PaceCaret from "../test/pace-caret"; -import * as TimerProgress from "../test/timer-progress"; -import * as Focus from "../test/focus"; -import * as ShiftTracker from "../test/shift-tracker"; -import * as Replay from "../test/replay"; -import * as MonkeyPower from "../elements/monkey-power"; -import * as Notifications from "../elements/notifications"; -import * as WeakSpot from "../test/weak-spot"; -import * as ActivePage from "../states/active-page"; -import * as TestState from "../test/test-state"; -import * as CompositionState from "../states/composition"; -import * as TestInput from "../test/test-input"; -import * as TestWords from "../test/test-words"; -import * as Hangul from "hangul-js"; -import * as CustomTextState from "../states/custom-text-name"; -import * as KeymapEvent from "../observables/keymap-event"; -import { IgnoredKeys } from "../constants/ignored-keys"; -import { ModifierKeys } from "../constants/modifier-keys"; -import { navigate } from "./route-controller"; -import * as Loader from "../elements/loader"; -import * as KeyConverter from "../utils/key-converter"; -import { - findSingleActiveFunboxWithFunction, - getActiveFunboxesWithFunction, - isFunboxActiveWithProperty, - getActiveFunboxNames, -} from "../test/funbox/list"; -import { tryCatchSync } from "@monkeytype/util/trycatch"; -import { canQuickRestart } from "../utils/quick-restart"; -import * as PageTransition from "../states/page-transition"; -import { areCharactersVisuallyEqual } from "../utils/strings"; - -let dontInsertSpace = false; -let correctShiftUsed = true; -let isKoCompiling = false; -let isBackspace: boolean; -let incorrectShiftsInARow = 0; -let awaitingNextWord = false; - -const wordsInput = document.getElementById("wordsInput") as HTMLInputElement; -const koInputVisual = document.getElementById("koInputVisual") as HTMLElement; - -function setWordsInput(value: string): void { - // Only change #wordsInput if it's not already the wanted value - // Avoids Safari triggering unneeded events, causing issues with - // dead keys. - // console.log("settings words input to " + value); - if (value !== wordsInput.value) { - wordsInput.value = value; - } -} - -function updateUI(): void { - const acc: number = Numbers.roundTo2(TestStats.calculateAccuracy()); - if (!isNaN(acc)) LiveAcc.update(acc); - - if (Config.keymapMode === "next" && Config.mode !== "zen") { - if (!Config.language.startsWith("korean")) { - void KeymapEvent.highlight( - TestWords.words.getCurrent().charAt(TestInput.input.current.length) - ); - } else { - //word [가다] - //Get the current korean word and group it [[ㄱ,ㅏ],[ㄷ,ㅏ]]. - const koCurrWord: string[][] = Hangul.disassemble( - TestWords.words.getCurrent(), - true - ); - const koCurrInput: string[][] = Hangul.disassemble( - TestInput.input.current, - true - ); - const inputGroupLength: number = koCurrInput.length - 1; - if (koCurrInput[inputGroupLength]) { - const inputCharLength: number = koCurrInput[inputGroupLength].length; - //at the end of the word, it will throw a (reading '0') this will be the space - try { - //if it overflows and returns undefined (e.g input [ㄱ,ㅏ,ㄷ]), - //take the difference between the overflow and the word - - //@ts-expect-error really cant be bothered fixing all these issues - its gonna get caught anyway - const koChar: string = - //@ts-expect-error --- - koCurrWord[inputGroupLength][inputCharLength] ?? - //@ts-expect-error --- - koCurrWord[koCurrInput.length][ - //@ts-expect-error --- - inputCharLength - koCurrWord[inputGroupLength].length - ]; - - void KeymapEvent.highlight(koChar); - } catch (e) { - void KeymapEvent.highlight(""); - } - } else { - //for new words - const toHighlight = koCurrWord?.[0]?.[0]; - if (toHighlight !== undefined) void KeymapEvent.highlight(toHighlight); - } - } - } -} - -function backspaceToPrevious(): void { - if (!TestState.isActive) return; - - const previousWordEl = TestUI.getWordElement(TestState.activeWordIndex - 1); - - const isFirstWord = TestInput.input.getHistory().length === 0; - const isFirstVisibleWord = previousWordEl === null; - const isPreviousWordHidden = previousWordEl?.classList.contains("hidden"); - const isPreviousWordCorrect = - TestInput.input.getHistory(TestState.activeWordIndex - 1) === - TestWords.words.get(TestState.activeWordIndex - 1); - - if ( - isFirstWord || - isFirstVisibleWord || - isPreviousWordHidden || - (isPreviousWordCorrect && !Config.freedomMode) || - Config.confidenceMode === "on" || - Config.confidenceMode === "max" - ) { - return; - } - - const activeWordEl = TestUI.getActiveWordElement(); - - const incorrectLetterBackspaced = - activeWordEl?.children[0]?.classList.contains("incorrect"); - if (Config.stopOnError === "letter" && incorrectLetterBackspaced) { - void TestUI.updateActiveWordLetters(); - } - - TestInput.input.current = TestInput.input.popHistory(); - TestInput.corrected.popHistory(); - if (isFunboxActiveWithProperty("nospace")) { - TestInput.input.current = TestInput.input.current.slice(0, -1); - setWordsInput(" " + TestInput.input.current + " "); - } - TestState.decreaseActiveWordIndex(); - TestUI.updateActiveElement(true); - Funbox.toggleScript(TestWords.words.getCurrent()); - void TestUI.updateActiveWordLetters(); - - if (Config.mode === "zen") { - TimerProgress.update(); - - const els = (document.querySelector("#words")?.children ?? - []) as HTMLElement[]; - - for (let i = els.length - 1; i >= 0; i--) { - const el = els[i] as HTMLElement; - if ( - el.classList.contains("newline") || - el.classList.contains("beforeNewline") || - el.classList.contains("afterNewline") - ) { - el.remove(); - } else { - break; - } - } - } - - Caret.updatePosition(); - Replay.addReplayEvent("backWord"); -} - -async function handleSpace(): Promise { - if (!TestState.isActive) return; - - if (TestInput.input.current === "") return; - - if ( - CompositionState.getComposing() && - Config.language.startsWith("chinese") - ) { - return; - } - - const currentWord: string = TestWords.words.getCurrent(); - - for (const fb of getActiveFunboxesWithFunction("handleSpace")) { - fb.functions.handleSpace(); - } - - dontInsertSpace = true; - - const burst: number = TestStats.calculateBurst(); - void LiveBurst.update(Math.round(burst)); - TestInput.pushBurstToHistory(burst); - - const nospace = isFunboxActiveWithProperty("nospace"); - - //correct word or in zen mode - const isWordCorrect: boolean = - currentWord === TestInput.input.current || Config.mode === "zen"; - void MonkeyPower.addPower(isWordCorrect, true); - TestInput.incrementAccuracy(isWordCorrect); - if (isWordCorrect) { - if (Config.stopOnError === "letter") { - void TestUI.updateActiveWordLetters(); - } - PaceCaret.handleSpace(true, currentWord); - TestInput.input.pushHistory(); - TestState.increaseActiveWordIndex(); - Funbox.toggleScript(TestWords.words.getCurrent()); - TestInput.incrementKeypressCount(); - TestInput.pushKeypressWord(TestState.activeWordIndex); - if (!nospace) { - void Sound.playClick(); - } - Replay.addReplayEvent("submitCorrectWord"); - } else { - if (!nospace) { - if (Config.playSoundOnError === "off" || Config.blindMode) { - void Sound.playClick(); - } else { - void Sound.playError(); - } - } - TestInput.pushMissedWord(TestWords.words.getCurrent()); - TestInput.incrementKeypressErrors(); - const cil: number = TestInput.input.current.length; - if (cil <= TestWords.words.getCurrent().length) { - if (cil >= TestInput.corrected.current.length) { - TestInput.corrected.current += "_"; - } else { - TestInput.corrected.current = - TestInput.corrected.current.substring(0, cil) + - "_" + - TestInput.corrected.current.substring(cil + 1); - } - } - if (Config.stopOnError !== "off") { - if (Config.difficulty === "expert" || Config.difficulty === "master") { - //failed due to diff when pressing space - TestLogic.fail("difficulty"); - return; - } - if (Config.stopOnError === "word") { - dontInsertSpace = false; - Replay.addReplayEvent("incorrectLetter", "_"); - void TestUI.updateActiveWordLetters(); - Caret.updatePosition(); - } - return; - } - PaceCaret.handleSpace(false, currentWord); - if (Config.blindMode) { - if (Config.highlightMode !== "off") { - TestUI.highlightAllLettersAsCorrect(TestState.activeWordIndex); - } - } else { - TestUI.highlightBadWord(TestState.activeWordIndex); - } - TestInput.input.pushHistory(); - TestState.increaseActiveWordIndex(); - Funbox.toggleScript(TestWords.words.getCurrent()); - TestInput.incrementKeypressCount(); - TestInput.pushKeypressWord(TestState.activeWordIndex); - Replay.addReplayEvent("submitErrorWord"); - if (Config.difficulty === "expert" || Config.difficulty === "master") { - TestLogic.fail("difficulty"); - } - } - - TestInput.corrected.pushHistory(); - - const isLastWord = TestState.activeWordIndex === TestWords.words.length; - if (TestLogic.areAllTestWordsGenerated() && isLastWord) { - void TestLogic.finish(); - return; - } - - let wordLength: number; - if (Config.mode === "zen") { - wordLength = TestInput.input.current.length; - } else { - wordLength = TestWords.words.getCurrent().length; - } - - const flex: number = Misc.whorf(Config.minBurstCustomSpeed, wordLength); - if ( - (Config.minBurst === "fixed" && burst < Config.minBurstCustomSpeed) || - (Config.minBurst === "flex" && burst < flex) - ) { - TestLogic.fail("min burst"); - return; - } - - if (Config.keymapMode === "react") { - void KeymapEvent.flash(" ", true); - } - if ( - Config.mode === "words" || - Config.mode === "custom" || - Config.mode === "quote" || - Config.mode === "zen" - ) { - TimerProgress.update(); - } - if (isLastWord) { - awaitingNextWord = true; - Loader.show(); - await TestLogic.addWord(); - Loader.hide(); - awaitingNextWord = false; - } else { - void TestLogic.addWord(); - } - TestUI.updateActiveElement(); - - const shouldLimitToThreeLines = - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") || - (Config.mode === "custom" && CustomText.getLimitValue() === 0); - - if (!Config.showAllLines || shouldLimitToThreeLines) { - const currentTop: number = Math.floor( - TestUI.getWordElement(TestState.activeWordIndex - 1)?.offsetTop ?? 0 - ); - - const { data: nextTop } = tryCatchSync(() => - Math.floor(TestUI.getActiveWordElement()?.offsetTop ?? 0) - ); - - if ((nextTop ?? 0) > currentTop) { - void TestUI.lineJump(currentTop); - } - } //end of line wrap - - Caret.updatePosition(); - - // enable if i decide that auto tab should also work after a space - // if ( - // Config.language.startsWith("code") && - // /^\t+/.test(TestWords.words.getCurrent()) && - // TestWords.words.getCurrent()[TestInput.input.current.length] === "\t" - // ) { - // //send a tab event using jquery - // $("#wordsInput").trigger($.Event("keydown", { key: "Tab", code: "Tab" })); - // } -} - -function isCharCorrect(char: string, charIndex: number): boolean { - if (!correctShiftUsed) return false; - - if (Config.mode === "zen") { - return true; - } - - //Checking for Korean char - if (TestInput.input.getKoreanStatus()) { - //disassembles Korean current Test word to check against char Input - const koWordArray: string[] = Hangul.disassemble( - TestWords.words.getCurrent() - ); - const koOriginalChar = koWordArray[charIndex]; - - if (koOriginalChar === undefined) { - return false; - } - - return koOriginalChar === char; - } - - const originalChar = TestWords.words.getCurrent()[charIndex]; - - if (originalChar === undefined) { - return false; - } - - if (originalChar === char) { - return true; - } - - const funbox = findSingleActiveFunboxWithFunction("isCharCorrect"); - if (funbox) { - return funbox.functions.isCharCorrect(char, originalChar); - } - - if (Config.language.startsWith("russian")) { - if ( - (char === "ё" || char === "е" || char === "e") && - (originalChar === "ё" || originalChar === "е" || originalChar === "e") - ) { - return true; - } - } - - const visuallyEqual = areCharactersVisuallyEqual(char, originalChar); - if (visuallyEqual) { - return true; - } - - return false; -} - -async function handleChar( - char: string, - charIndex: number, - realInputValue?: string -): Promise { - if (TestUI.resultCalculating || TestState.resultVisible) { - return; - } - - if (char === "…" && TestWords.words.getCurrent()[charIndex] !== "…") { - for (let i = 0; i < 3; i++) { - await handleChar(".", charIndex + i); - } - - return; - } - - console.debug("Handling char", char, charIndex, realInputValue); - - const now = performance.now(); - - const isCharKorean: boolean = TestInput.input.getKoreanStatus(); - - for (const fb of getActiveFunboxesWithFunction("handleChar")) { - char = fb.functions.handleChar(char); - } - - const nospace = isFunboxActiveWithProperty("nospace"); - - if (char !== "\n" && char !== "\t" && /\s/.test(char)) { - if (nospace) return; - void handleSpace(); - - //insert space for expert and master or strict space, - //or for stop on error set to word, - //otherwise dont do anything - if ( - Config.difficulty !== "normal" || - (Config.strictSpace && Config.mode !== "zen") || - (Config.stopOnError === "word" && charIndex > 0) - ) { - if (dontInsertSpace) { - dontInsertSpace = false; - return; - } - } else { - return; - } - } - - if ( - Config.mode !== "zen" && - TestWords.words.getCurrent()[charIndex] !== "\n" && - char === "\n" - ) { - return; - } - - //start the test - if (!TestState.isActive && !TestLogic.startTest(now)) { - return; - } - - Focus.set(true); - Caret.stopAnimation(); - - const thisCharCorrect: boolean = isCharCorrect(char, charIndex); - let resultingWord: string; - - if (thisCharCorrect && Config.mode !== "zen") { - char = !isCharKorean - ? TestWords.words.getCurrent().charAt(charIndex) - : Hangul.disassemble(TestWords.words.getCurrent())[charIndex] ?? ""; - } - - if (!thisCharCorrect && char === "\n") { - if (TestInput.input.current === "") return; - char = " "; - } - - if (TestInput.input.current === "") { - TestInput.setBurstStart(now); - } - - if (isCharKorean || Config.language.startsWith("korean")) { - // Get real input from #WordsInput char call. - // This is because the chars can't be confirmed correctly. - // With chars alone this happens when a previous symbol is completed - // Example: - // input history: ['프'], input:ㄹ, expected :프ㄹ, result: 플 - const realInput: string = (realInputValue ?? "").slice(1); - resultingWord = realInput; - koInputVisual.innerText = resultingWord.slice(-1); - } else if (Config.language.startsWith("chinese")) { - resultingWord = (realInputValue ?? "").slice(1); - } else { - resultingWord = - TestInput.input.current.substring(0, charIndex) + - char + - TestInput.input.current.substring(charIndex + 1); - } - - // If a trailing composed char is used, ignore it when counting accuracy - if ( - !thisCharCorrect && - // Misc.trailingComposeChars.test(resultingWord) && - CompositionState.getComposing() && - !Config.language.startsWith("korean") - ) { - TestInput.input.current = resultingWord; - void TestUI.updateActiveWordLetters(); - Caret.updatePosition(); - return; - } - - void MonkeyPower.addPower(thisCharCorrect); - TestInput.incrementAccuracy(thisCharCorrect); - - if (!thisCharCorrect) { - TestInput.incrementKeypressErrors(); - TestInput.pushMissedWord(TestWords.words.getCurrent()); - } - - WeakSpot.updateScore( - Config.mode === "zen" - ? char - : TestWords.words.getCurrent()[charIndex] ?? "", - thisCharCorrect - ); - - if (thisCharCorrect) { - void Sound.playClick(); - } else { - if (Config.playSoundOnError === "off" || Config.blindMode) { - void Sound.playClick(); - } else { - void Sound.playError(); - } - } - - //keymap - if (Config.keymapMode === "react") { - void KeymapEvent.flash(char, thisCharCorrect); - } - - if (Config.difficulty !== "master") { - if (!correctShiftUsed) { - incorrectShiftsInARow++; - if (incorrectShiftsInARow >= 5) { - Notifications.add("Opposite shift mode is on.", 0, { - important: true, - customTitle: "Reminder", - }); - } - return; - } else { - incorrectShiftsInARow = 0; - } - } - - //update current corrected version. if its empty then add the current char. if its not then replace the last character with the currently pressed one / add it - if (TestInput.corrected.current === "") { - TestInput.corrected.current += !isCharKorean - ? resultingWord - : Hangul.disassemble(resultingWord).join(""); - } else { - const currCorrectedTestInputLength: number = !isCharKorean - ? TestInput.corrected.current.length - : Hangul.disassemble(TestInput.corrected.current).length; - - if (charIndex >= currCorrectedTestInputLength) { - TestInput.corrected.current += !isCharKorean - ? char - : Hangul.disassemble(char).concat().join(""); - } else if (!thisCharCorrect) { - TestInput.corrected.current = - TestInput.corrected.current.substring(0, charIndex) + - char + - TestInput.corrected.current.substring(charIndex + 1); - } - } - - TestInput.incrementKeypressCount(); - TestInput.pushKeypressWord(TestState.activeWordIndex); - - if ( - Config.difficulty !== "master" && - Config.stopOnError === "letter" && - !thisCharCorrect - ) { - if (!Config.blindMode) { - void TestUI.updateActiveWordLetters(TestInput.input.current + char); - } - return; - } - - Replay.addReplayEvent( - thisCharCorrect ? "correctLetter" : "incorrectLetter", - char - ); - - const activeWord = TestUI.getActiveWordElement() as HTMLElement; - - const testInputLength: number = !isCharKorean - ? TestInput.input.current.length - : Hangul.disassemble(TestInput.input.current).length; - //update the active word top, but only once - if (testInputLength === 1 && TestState.activeWordIndex === 0) { - TestUI.setActiveWordTop(activeWord?.offsetTop); - } - - //max length of the input is 20 unless in zen mode then its 30 - if ( - (Config.mode === "zen" && charIndex < 30) || - (Config.mode !== "zen" && - resultingWord.length < TestWords.words.getCurrent().length + 20) - ) { - TestInput.input.current = resultingWord; - } else { - console.error("Hitting word limit"); - } - - if (!thisCharCorrect && Config.difficulty === "master") { - TestLogic.fail("difficulty"); - return; - } - - if (Config.mode !== "zen") { - //not applicable to zen mode - //auto stop the test if the last word is correct - //do not stop if not all characters have been parsed by handleChar yet - const currentWord = TestWords.words.getCurrent(); - const lastWordIndex = TestState.activeWordIndex; - const lastWord = lastWordIndex === TestWords.words.length - 1; - const allWordGenerated = TestLogic.areAllTestWordsGenerated(); - const wordIsTheSame = currentWord === TestInput.input.current; - const shouldQuickEnd = - Config.quickEnd && - !Config.language.startsWith("korean") && - currentWord.length === TestInput.input.current.length && - Config.stopOnError === "off"; - const isChinese = Config.language.startsWith("chinese"); - - if ( - lastWord && - allWordGenerated && - (wordIsTheSame || shouldQuickEnd) && - (!isChinese || - (realInputValue !== undefined && - charIndex + 2 === realInputValue.length)) - ) { - void TestLogic.finish(); - return; - } - } - - const activeWordTopBeforeJump = activeWord?.offsetTop; - await TestUI.updateActiveWordLetters(); - - const newActiveTop = activeWord?.offsetTop; - //stop the word jump by slicing off the last character, update word again - // dont do it in replace typos, because it might trigger in the middle of a wrd - // when using non monospace fonts - /** - * NOTE: this input length > 1 guard, added in commit bc94a64, - * aimed to prevent some input blocking issue after test restarts. - * - * This check was found to cause a jump to a hidden 3rd line bug in zen mode (#6697) - * So commented due to the zen bug and the original issue not being reproducible, - * - * REVISIT this logic if any INPUT or WORD JUMP issues reappear. - */ - if ( - activeWordTopBeforeJump < newActiveTop && - !TestUI.lineTransition - // TestInput.input.current.length > 1 - ) { - if ( - Config.mode === "zen" || - Config.indicateTypos === "replace" || - Config.indicateTypos === "both" - ) { - if (!Config.showAllLines) void TestUI.lineJump(activeWordTopBeforeJump); - } else { - TestInput.input.current = TestInput.input.current.slice(0, -1); - await TestUI.updateActiveWordLetters(); - } - } - - //simulate space press in nospace funbox - if ( - (nospace && - TestInput.input.current.length === TestWords.words.getCurrent().length) || - (char === "\n" && thisCharCorrect) - ) { - void handleSpace(); - } - - const currentWord = TestWords.words.getCurrent(); - const doesCurrentWordHaveTab = /^\t+/.test(TestWords.words.getCurrent()); - const isCurrentCharTab = currentWord[TestInput.input.current.length] === "\t"; - - setTimeout(() => { - if ( - thisCharCorrect && - Config.language.startsWith("code") && - doesCurrentWordHaveTab && - isCurrentCharTab - ) { - const tabEvent = new KeyboardEvent("keydown", { - key: "Tab", - code: "Tab", - }); - document.dispatchEvent(tabEvent); - } - }, 0); - - if (char !== "\n") { - Caret.updatePosition(); - } -} - -async function handleTab( - event: JQuery.KeyDownEvent, - popupVisible: boolean -): Promise { - if (TestUI.resultCalculating) { - event.preventDefault(); - return; - } - - let shouldInsertTabCharacter = false; - - if ( - (Config.mode === "zen" && !event.shiftKey) || - (TestWords.hasTab && !event.shiftKey) - ) { - shouldInsertTabCharacter = true; - } - - const modalVisible: boolean = - Misc.isPopupVisible("commandLineWrapper") || popupVisible; - - if (Config.quickRestart === "esc") { - // dont do anything special - if (modalVisible) return; - - // dont do anything on login so we can tab/esc between inputs - if (ActivePage.get() === "login") return; - - event.preventDefault(); - // insert tab character if needed (only during the test) - if (!TestState.resultVisible && shouldInsertTabCharacter) { - await handleChar("\t", TestInput.input.current.length); - setWordsInput(" " + TestInput.input.current); - return; - } - } else if (Config.quickRestart === "tab") { - // dont do anything special - if (modalVisible) return; - - // dont do anything on login so we can tab/esc betweeen inputs - if (ActivePage.get() === "login") return; - - // change page if not on test page - if (ActivePage.get() !== "test") { - await navigate("/"); - return; - } - - // in case we are in a long test, setting manual restart - if (event.shiftKey) { - ManualRestart.set(); - } else { - ManualRestart.reset(); - } - - // insert tab character if needed (only during the test) - if (!TestState.resultVisible && shouldInsertTabCharacter) { - event.preventDefault(); - await handleChar("\t", TestInput.input.current.length); - setWordsInput(" " + TestInput.input.current); - return; - } - - //otherwise restart - TestLogic.restart({ event }); - } else { - //quick tab off - // dont do anything special - if (modalVisible) return; - - //only special handlig on the test page - if (ActivePage.get() !== "test") return; - if (TestState.resultVisible) return; - - // insert tab character if needed - if (shouldInsertTabCharacter) { - event.preventDefault(); - await handleChar("\t", TestInput.input.current.length); - setWordsInput(" " + TestInput.input.current); - return; - } - - setTimeout(() => { - if (document.activeElement?.id !== "wordsInput") { - Focus.set(false); - } - }, 0); - } -} - -$("#wordsInput").on("keydown", (event) => { - const pageTestActive: boolean = ActivePage.get() === "test"; - const commandLineVisible = Misc.isPopupVisible("commandLineWrapper"); - const popupVisible: boolean = Misc.isAnyPopupVisible(); - const allowTyping: boolean = - pageTestActive && - !commandLineVisible && - !popupVisible && - !TestState.resultVisible && - event.key !== "Enter" && - !awaitingNextWord && - TestState.testInitSuccess; - - if (!allowTyping) { - event.preventDefault(); - } -}); - -let lastBailoutAttempt = -1; - -$(document).on("keydown", async (event) => { - if (PageTransition.get()) { - console.debug("Ignoring keydown during page transition."); - return; - } - - if (IgnoredKeys.includes(event.key)) { - console.debug( - `Key ${event.key} is on the list of ignored keys. Stopping keydown event.` - ); - event.preventDefault(); - return; - } - - for (const fb of getActiveFunboxesWithFunction("handleKeydown")) { - void fb.functions.handleKeydown(event); - } - - //autofocus - const wordsFocused: boolean = $("#wordsInput").is(":focus"); - const pageTestActive: boolean = ActivePage.get() === "test"; - const commandLineVisible = Misc.isPopupVisible("commandLineWrapper"); - - const popupVisible: boolean = Misc.isAnyPopupVisible(); - - const allowTyping: boolean = - pageTestActive && - !commandLineVisible && - !popupVisible && - !TestState.resultVisible && - (wordsFocused || event.key !== "Enter") && - !awaitingNextWord; - - if ( - allowTyping && - !wordsFocused && - !["Enter", " ", "Escape", "Tab", ...ModifierKeys].includes(event.key) - ) { - TestUI.focusWords(); - if (Config.showOutOfFocusWarning && !event.ctrlKey && !event.metaKey) { - event.preventDefault(); - } - } - - //tab - if (event.key === "Tab") { - await handleTab(event, popupVisible); - } - - //esc - if (event.key === "Escape" && Config.quickRestart === "esc") { - const modalVisible: boolean = - Misc.isPopupVisible("commandLineWrapper") || popupVisible; - - if (modalVisible) return; - - // change page if not on test page - if (ActivePage.get() !== "test") { - await navigate("/"); - return; - } - - // in case we are in a long test, setting manual restart - if (event.shiftKey) { - ManualRestart.set(); - } else { - ManualRestart.reset(); - } - - //otherwise restart - TestLogic.restart({ - event, - }); - } - - //enter - if (event.key === "Enter" && Config.quickRestart === "enter") { - //check if active element is a button, anchor, or has class button, or textButton - const activeElement: HTMLElement | null = - document.activeElement as HTMLElement; - const activeElementIsButton: boolean = - activeElement?.tagName === "BUTTON" || - activeElement?.tagName === "A" || - activeElement?.classList.contains("button") || - activeElement?.classList.contains("textButton") || - (activeElement?.tagName === "INPUT" && - activeElement?.id !== "wordsInput"); - - if (activeElementIsButton) return; - - const modalVisible: boolean = - Misc.isPopupVisible("commandLineWrapper") || popupVisible; - - if (modalVisible) return; - - // change page if not on test page - if (ActivePage.get() !== "test") { - await navigate("/"); - return; - } - - if (TestState.resultVisible) { - TestLogic.restart({ - event, - }); - return; - } - - if (Config.mode === "zen") { - //do nothing - } else if ( - (!TestWords.hasNewline && !Config.funbox.includes("58008")) || - ((TestWords.hasNewline || Config.funbox.includes("58008")) && - event.shiftKey) - ) { - // in case we are in a long test, setting manual restart - if (event.shiftKey) { - ManualRestart.set(); - } else { - ManualRestart.reset(); - } - - //otherwise restart - TestLogic.restart({ - event, - }); - } - } - - if (!allowTyping) return; - - if (!event.originalEvent?.isTrusted || TestState.testRestarting) { - event.preventDefault(); - return; - } - - TestInput.setCurrentNotAfk(); - - //blocking firefox from going back in history with backspace - if (event.key === "Backspace") { - void Sound.playClick(); - const t = /INPUT|SELECT|TEXTAREA/i; - if ( - !t.test((event.target as unknown as Element).tagName) - // if this breaks in the future, call mio and tell him to stop being lazy - // (event.target as unknown as KeyboardEvent).disabled || - // (event.target as unknown as Element).readOnly - ) { - event.preventDefault(); - } - - if (Config.confidenceMode === "max") { - event.preventDefault(); - return; - } - - // if the user backspaces the indentation in a code language we need to empty - // the current word so the user is set back to the end of the last line - if ( - Config.codeUnindentOnBackspace && - TestInput.input.current.length > 0 && - /^\t*$/.test(TestInput.input.current) && - Config.language.startsWith("code") && - isCharCorrect( - TestInput.input.current.slice(-1), - TestInput.input.current.length - 1 - ) && - (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== - TestWords.words.get(TestState.activeWordIndex - 1) || - Config.freedomMode) - ) { - TestInput.input.current = ""; - await TestUI.updateActiveWordLetters(); - } - } - - if (event.key === "Backspace" && TestInput.input.current.length === 0) { - backspaceToPrevious(); - if (TestInput.input.current) { - setWordsInput(" " + TestInput.input.current + " "); - } - } - - if (event.key === "Enter") { - if (event.shiftKey) { - if (Config.mode === "zen") { - void TestLogic.finish(); - } else if ( - !canQuickRestart( - Config.mode, - Config.words, - Config.time, - CustomText.getData(), - CustomTextState.isCustomTextLong() ?? false - ) - ) { - const delay = Date.now() - lastBailoutAttempt; - if (lastBailoutAttempt === -1 || delay > 200) { - lastBailoutAttempt = Date.now(); - if (delay >= 5000) { - Notifications.add( - "Please double tap shift+enter to confirm bail out", - 0, - { - important: true, - duration: 5, - } - ); - } - } else { - TestState.setBailedOut(true); - void TestLogic.finish(); - } - } else { - await handleChar("\n", TestInput.input.current.length); - setWordsInput(" " + TestInput.input.current); - updateUI(); - } - } else { - await handleChar("\n", TestInput.input.current.length); - setWordsInput(" " + TestInput.input.current); - updateUI(); - } - } - - //show dead keys - if (event.key === "Dead" && !CompositionState.getComposing()) { - void Sound.playClick(); - const activeWord = TestUI.getActiveWordElement(); - const len: number = TestInput.input.current.length; // have to do this because prettier wraps the line and causes an error - - // Check to see if the letter actually exists to toggle it as dead - const deadLetter: Element | undefined = - activeWord?.querySelectorAll("letter")[len]; - if (deadLetter) { - deadLetter.classList.toggle("dead"); - } - } - - if (Config.oppositeShiftMode !== "off") { - if ( - Config.oppositeShiftMode === "keymap" && - Config.keymapLayout !== "overrideSync" - ) { - let keymapLayout = await JSONData.getLayout(Config.keymapLayout).catch( - () => undefined - ); - - if (keymapLayout === undefined) { - Notifications.add("Failed to load keymap layout", -1); - - return; - } - - const funbox = getActiveFunboxNames().includes("layout_mirror"); - if (funbox) { - keymapLayout = KeyConverter.mirrorLayoutKeys(keymapLayout); - } - - const keycode = KeyConverter.layoutKeyToKeycode(event.key, keymapLayout); - - correctShiftUsed = - keycode === undefined - ? true - : ShiftTracker.isUsingOppositeShift(keycode); - } else { - correctShiftUsed = ShiftTracker.isUsingOppositeShift( - event.code as KeyConverter.Keycode - ); - } - } - - for (const fb of getActiveFunboxesWithFunction("preventDefaultEvent")) { - if ( - await fb.functions.preventDefaultEvent( - //i cant figure this type out, but it works fine - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - event as JQuery.KeyDownEvent - ) - ) { - event.preventDefault(); - await handleChar(event.key, TestInput.input.current.length); - updateUI(); - setWordsInput(" " + TestInput.input.current); - } - } - - if ( - Config.layout !== "default" && - !(event.ctrlKey || (event.altKey && Misc.isLinux())) - ) { - const char: string | null = await LayoutEmulator.getCharFromEvent(event); - if (char !== null) { - event.preventDefault(); - await handleChar(char, TestInput.input.current.length); - updateUI(); - setWordsInput(" " + TestInput.input.current); - } - } - - isBackspace = event.key === "Backspace" || event.key === "delete"; -}); - -$("#wordsInput").on("keydown", (event) => { - if (event.originalEvent?.repeat) { - console.log( - "spacing debug keydown STOPPED - repeat", - event.key, - event.code, - //ignore for logging - // eslint-disable-next-line @typescript-eslint/no-deprecated - event.which - ); - return; - } - - Monkey.type(event); - // console.debug("Event: keydown", event); - - if (event.code === "NumpadEnter" && Config.funbox.includes("58008")) { - event.code = "Space"; - } - - if (event.code.includes("Arrow") && Config.funbox.includes("arrows")) { - event.code = "NoCode"; - } - - const now = performance.now(); - const eventCode = - event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; - TestInput.recordKeydownTime(now, eventCode); -}); - -$("#wordsInput").on("keyup", (event) => { - if (event.originalEvent?.repeat) { - console.log( - "spacing debug keydown STOPPED - repeat", - event.key, - event.code, - //ignore for logging - // eslint-disable-next-line @typescript-eslint/no-deprecated - event.which - ); - return; - } - - // console.debug("Event: keyup", event); - - if (event.code === "NumpadEnter" && Config.funbox.includes("58008")) { - event.code = "Space"; - } - - if (event.code.includes("Arrow") && Config.funbox.includes("arrows")) { - event.code = "NoCode"; - } - - const now = performance.now(); - const eventCode = - event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code; - TestInput.recordKeyupTime(now, eventCode); -}); - -$("#wordsInput").on("keyup", (event) => { - if (!event.originalEvent?.isTrusted || TestState.testRestarting) { - event.preventDefault(); - return; - } - - Monkey.stop(event); - - if (IgnoredKeys.includes(event.key)) return; - - if (TestState.resultVisible) return; -}); - -$("#wordsInput").on("beforeinput", (event) => { - if (!event.originalEvent?.isTrusted) return; - if ((event.target as HTMLInputElement).value === "") { - (event.target as HTMLInputElement).value = " "; - } -}); - -$("#wordsInput").on("input", async (event) => { - if (!event.originalEvent?.isTrusted || TestState.testRestarting) { - (event.target as HTMLInputElement).value = " "; - return; - } - - const popupVisible = Misc.isAnyPopupVisible(); - if (popupVisible) { - event.preventDefault(); - return; - } - - TestInput.setCurrentNotAfk(); - - if ( - (Config.layout === "default" || Config.layout === "korean") && - (event.target as HTMLInputElement).value - .normalize() - .match( - /[\uac00-\ud7af]|[\u1100-\u11ff]|[\u3130-\u318f]|[\ua960-\ua97f]|[\ud7b0-\ud7ff]/g - ) - ) { - TestInput.input.setKoreanStatus(true); - } - - const containsKorean = TestInput.input.getKoreanStatus(); - const containsChinese = Config.language.startsWith("chinese"); - - //Hangul.disassemble breaks down Korean characters into its components - //allowing it to be treated as normal latin characters - //Hangul.disassemble('한글') //['ㅎ','ㅏ','ㄴ','ㄱ','ㅡ','ㄹ'] - //Hangul.disassemble('한글',true) //[['ㅎ','ㅏ','ㄴ'],['ㄱ','ㅡ','ㄹ']] - const realInputValue = (event.target as HTMLInputElement).value.normalize(); - const inputValue = containsKorean - ? Hangul.disassemble(realInputValue).join("").slice(1) - : realInputValue.slice(1); - - const currTestInput = containsKorean - ? Hangul.disassemble(TestInput.input.current).join("") - : TestInput.input.current; - - //checks to see if a korean word has compiled into two characters. - //inputs: ㄱ, 가, 갇, 가다 - //what it actually reads: ㄱ, 가, 갇, , 가, 가다 - //this skips this part (, , 가,) - if (containsKorean && !isBackspace) { - if ( - isKoCompiling || - (realInputValue.slice(1).length < TestInput.input.current.length && - Hangul.disassemble(TestInput.input.current.slice(-1)).length > 1) - ) { - isKoCompiling = !isKoCompiling; - return; - } - } - - // input will be modified even with the preventDefault() in - // beforeinput/keydown if it's part of a compose sequence. this undoes - // the effects of that and takes the input out of compose mode. - if ( - Config.layout !== "default" && - inputValue.length >= currTestInput.length - ) { - setWordsInput(" " + currTestInput); - updateUI(); - return; - } - - if (realInputValue.length === 0 && currTestInput.length === 0) { - // fallback for when no Backspace keydown event (mobile) - backspaceToPrevious(); - } else if (inputValue.length < currTestInput.length) { - if (containsChinese) { - if ( - currTestInput.length - inputValue.length <= 2 && - currTestInput.startsWith(currTestInput) - ) { - TestInput.input.current = inputValue; - } else { - // IME has converted pinyin to Chinese Character(s) - let diffStart = 0; - while (inputValue[diffStart] === currTestInput[diffStart]) { - diffStart++; - } - - let iOffset = 0; - if (Config.stopOnError !== "word" && /.+ .+/.test(inputValue)) { - iOffset = inputValue.indexOf(" ") + 1; - } - for (let i = diffStart; i < inputValue.length; i++) { - await handleChar( - inputValue[i] as string, - i - iOffset, - realInputValue - ); - } - } - } else if (containsKorean) { - const realInput = (event.target as HTMLInputElement).value - .normalize() - .slice(1); - - TestInput.input.current = realInput; - koInputVisual.innerText = realInput.slice(-1); - } else { - TestInput.input.current = inputValue; - } - - void TestUI.updateActiveWordLetters(); - Caret.updatePosition(); - if (!CompositionState.getComposing()) { - const keyStroke = event?.originalEvent as InputEvent; - if (keyStroke.inputType === "deleteWordBackward") { - Replay.addReplayEvent("setLetterIndex", 0); // Letter index will be 0 on CTRL + Backspace Event - } else { - Replay.addReplayEvent("setLetterIndex", currTestInput.length - 1); - } - } - } - if (inputValue !== currTestInput) { - let diffStart = 0; - while (inputValue[diffStart] === currTestInput[diffStart]) { - diffStart++; - } - - let iOffset = 0; - if (Config.stopOnError !== "word" && /.+ .+/.test(inputValue)) { - iOffset = inputValue.indexOf(" ") + 1; - } - for (let i = diffStart; i < inputValue.length; i++) { - // passing realInput to allow for correct Korean character compilation - await handleChar(inputValue[i] as string, i - iOffset, realInputValue); - } - } - - setWordsInput(" " + TestInput.input.current); - updateUI(); - const statebefore = CompositionState.getComposing(); - setTimeout(() => { - // checking composition state during the input event and on the next loop - // this is done because some browsers (e.g. Chrome) will fire the input - // event before the compositionend event. - // this ensures the UI is correct - - const stateafter = CompositionState.getComposing(); - if (statebefore !== stateafter) { - void TestUI.updateActiveWordLetters(); - } - - // force caret at end of input - // doing it on next cycle because Chromium on Android won't let me edit - // the selection inside the input event - if ( - (event.target as HTMLInputElement).selectionStart !== - (event.target as HTMLInputElement).value.length && - (!Misc.trailingComposeChars.test( - (event.target as HTMLInputElement).value - ) || - ((event.target as HTMLInputElement).selectionStart ?? 0) < - (event.target as HTMLInputElement).value.search( - Misc.trailingComposeChars - )) - ) { - (event.target as HTMLInputElement).selectionStart = ( - event.target as HTMLInputElement - ).selectionEnd = (event.target as HTMLInputElement).value.length; - } - }, 0); -}); - -document.querySelector("#wordsInput")?.addEventListener("focus", (event) => { - const target = event.target as HTMLInputElement; - const value = target.value; - target.setSelectionRange(value.length, value.length); -}); - -$("#wordsInput").on("copy paste", (event) => { - event.preventDefault(); -}); - -$("#wordsInput").on("select selectstart", (event) => { - event.preventDefault(); -}); - -$("#wordsInput").on("selectionchange", (event) => { - const target = event.target as HTMLInputElement; - const value = target.value; - - const hasSelectedText = target.selectionStart !== target.selectionEnd; - const isCursorAtEnd = target.selectionStart === value.length; - - if (hasSelectedText || !isCursorAtEnd) { - // force caret at end of input - target.setSelectionRange(value.length, value.length); - } -}); - -$("#wordsInput").on("keydown", (event) => { - if (event.key.startsWith("Arrow")) { - event.preventDefault(); - } -}); - -// Composing events -$("#wordsInput").on("compositionstart", () => { - if (Config.layout !== "default") return; - CompositionState.setComposing(true); - CompositionState.setStartPos(TestInput.input.current.length); -}); - -$("#wordsInput").on("compositionend", () => { - if (Config.layout !== "default") return; - CompositionState.setComposing(false); -}); diff --git a/frontend/src/ts/elements/composition-display.ts b/frontend/src/ts/elements/composition-display.ts new file mode 100644 index 000000000000..908bbbf106c5 --- /dev/null +++ b/frontend/src/ts/elements/composition-display.ts @@ -0,0 +1,23 @@ +import Config from "../config"; + +const compositionDisplay = document.getElementById( + "compositionDisplay" +) as HTMLElement; + +const languagesToShow = ["korean", "japanese", "chinese"]; + +export function shouldShow(): boolean { + return languagesToShow.some((lang) => Config.language.startsWith(lang)); +} + +export function update(data: string): void { + compositionDisplay.innerText = data; +} + +export function hide(): void { + compositionDisplay.classList.add("hidden"); +} + +export function show(): void { + compositionDisplay.classList.remove("hidden"); +} diff --git a/frontend/src/ts/event-handlers/global.ts b/frontend/src/ts/event-handlers/global.ts index 8941c6254f4a..a5a436db666d 100644 --- a/frontend/src/ts/event-handlers/global.ts +++ b/frontend/src/ts/event-handlers/global.ts @@ -4,11 +4,37 @@ import Config from "../config"; import * as TestWords from "../test/test-words"; import * as Commandline from "../commandline/commandline"; import * as Notifications from "../elements/notifications"; +import * as ActivePage from "../states/active-page"; +import { ModifierKeys } from "../constants/modifier-keys"; +import { focusWords } from "../test/test-ui"; +import * as TestLogic from "../test/test-logic"; +import { navigate } from "../controllers/route-controller"; +import { isInputElementFocused } from "../input/input-element"; -document.addEventListener("keydown", async (e) => { +document.addEventListener("keydown", (e) => { if (PageTransition.get()) return; if (e.key === undefined) return; + const pageTestActive: boolean = ActivePage.get() === "test"; + + if (pageTestActive && !isInputElementFocused()) { + const popupVisible: boolean = Misc.isAnyPopupVisible(); + // this is nested because isAnyPopupVisible is a bit expensive + // and we don't want to call it during the test + if ( + !popupVisible && + !["Enter", " ", "Escape", "Tab", ...ModifierKeys].includes(e.key) && + !e.metaKey && + !e.ctrlKey + ) { + //autofocus + focusWords(); + if (Config.showOutOfFocusWarning) { + e.preventDefault(); + } + } + } + if ( (e.key === "Escape" && Config.quickRestart !== "esc") || (e.key === "Tab" && @@ -27,6 +53,33 @@ document.addEventListener("keydown", async (e) => { Commandline.show(); } } + + if (!isInputElementFocused()) { + const isInteractiveElement = + document.activeElement?.tagName === "INPUT" || + document.activeElement?.tagName === "TEXTAREA" || + document.activeElement?.tagName === "SELECT" || + document.activeElement?.tagName === "BUTTON" || + document.activeElement?.classList.contains("button") || + document.activeElement?.classList.contains("textButton"); + + if ( + (e.key === "Tab" && + Config.quickRestart === "tab" && + !isInteractiveElement) || + (e.key === "Escape" && Config.quickRestart === "esc") || + (e.key === "Enter" && + Config.quickRestart === "enter" && + !isInteractiveElement) + ) { + e.preventDefault(); + if (ActivePage.get() === "test") { + TestLogic.restart(); + } else { + void navigate(""); + } + } + } }); //stop space scrolling diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index ca12b70244d1..d2d9f2dc5cc0 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -26,7 +26,7 @@ import { enable } from "./states/glarses-mode"; import "./test/caps-warning"; import "./modals/simple-modals"; import * as CookiesModal from "./modals/cookies"; -import "./controllers/input-controller"; +import "./input/listeners"; import "./ready"; import "./controllers/route-controller"; import "./pages/about"; diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts new file mode 100644 index 000000000000..d704b7667469 --- /dev/null +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -0,0 +1,51 @@ +import Config from "../../config"; +import * as TestInput from "../../test/test-input"; +import * as TestState from "../../test/test-state"; +import * as TestWords from "../../test/test-words"; +import { getInputElementValue } from "../input-element"; +import * as TestUI from "../../test/test-ui"; + +export function onBeforeDelete(event: InputEvent): void { + if (!TestState.isActive) { + event.preventDefault(); + return; + } + const { inputValue } = getInputElementValue(); + const inputIsEmpty = inputValue === ""; + + if (inputIsEmpty) { + // this is nested because we only wanna pull the element from the dom if needed + const previousWordElement = TestUI.getWordElement( + TestState.activeWordIndex - 1 + ); + if (previousWordElement === null) { + event.preventDefault(); + return; + } + } + + if (Config.freedomMode) { + //allow anything in freedom mode + return; + } + + const confidence = Config.confidenceMode; + const previousWordCorrect = + (TestInput.input.get(TestState.activeWordIndex - 1) ?? "") === + TestWords.words.get(TestState.activeWordIndex - 1); + + if (confidence === "on" && inputIsEmpty && !previousWordCorrect) { + event.preventDefault(); + return; + } + + if (confidence === "max") { + event.preventDefault(); + return; + } + + if (inputIsEmpty && previousWordCorrect) { + event.preventDefault(); + return; + } +} diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts new file mode 100644 index 000000000000..38fec9fea65b --- /dev/null +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -0,0 +1,93 @@ +import Config from "../../config"; +import * as TestInput from "../../test/test-input"; +import * as TestState from "../../test/test-state"; +import * as TestUI from "../../test/test-ui"; +import * as TestWords from "../../test/test-words"; +import { isFunboxActiveWithProperty } from "../../test/funbox/list"; +import { isSpace } from "../../utils/strings"; +import { getInputElementValue } from "../input-element"; +import { isAwaitingNextWord } from "../state"; +import { shouldInsertSpaceCharacter } from "../helpers/validation"; + +/** + * Handles logic before inserting text into the input element. + * @param data - The text data to be inserted. + * @returns Whether to prevent the default insertion behavior. + */ +export function onBeforeInsertText(data: string): boolean { + if (TestState.testRestarting) { + return true; + } + + if (isAwaitingNextWord()) { + return true; + } + + if (TestUI.resultCalculating) { + return true; + } + + const { inputValue } = getInputElementValue(); + const dataIsSpace = isSpace(data); + const shouldInsertSpaceAsCharacter = shouldInsertSpaceCharacter({ + data, + inputValue, + targetWord: TestWords.words.getCurrent(), + }); + + //prevent space from being inserted if input is empty + //allow if strict space is enabled + if ( + dataIsSpace && + inputValue === "" && + Config.difficulty === "normal" && + !Config.strictSpace + ) { + return true; + } + + //prevent space in nospace funbox + if (dataIsSpace && isFunboxActiveWithProperty("nospace")) { + return true; + } + + //only allow newlines if the test has newlines + if (data === "\n" && !TestWords.hasNewline) { + return true; + } + + // block input if the word is too long + const inputLimit = + Config.mode === "zen" ? 30 : TestWords.words.getCurrent().length + 20; + const overLimit = TestInput.input.current.length >= inputLimit; + if (overLimit && (shouldInsertSpaceAsCharacter === true || !dataIsSpace)) { + console.error("Hitting word limit"); + return true; + } + + // prevent the word from jumping to the next line if the word is too long + // this will not work for the first word of each line, but that has a low chance of happening + // make sure to only check this when necessary (hide extra letters is off or input is longer than word) + // because this check is expensive (causes layout reflows) + + const dataIsNotFalsy = data !== null && data !== ""; + const inputIsLongerThanOrEqualToWord = + TestInput.input.current.length >= TestWords.words.getCurrent().length; + + if ( + dataIsNotFalsy && + !Config.blindMode && + !Config.hideExtraLetters && + inputIsLongerThanOrEqualToWord && + (shouldInsertSpaceAsCharacter === true || !dataIsSpace) && + Config.mode !== "zen" + ) { + const topAfterAppend = TestUI.getActiveWordTopAfterAppend(data); + const wordJumped = topAfterAppend > TestUI.activeWordTop; + if (wordJumped) { + return true; + } + } + + return false; +} diff --git a/frontend/src/ts/input/handlers/delete.ts b/frontend/src/ts/input/handlers/delete.ts new file mode 100644 index 000000000000..b6551c643636 --- /dev/null +++ b/frontend/src/ts/input/handlers/delete.ts @@ -0,0 +1,48 @@ +import * as TestUI from "../../test/test-ui"; +import * as TestWords from "../../test/test-words"; +import * as TestInput from "../../test/test-input"; +import { getInputElementValue, setInputElementValue } from "../input-element"; + +import * as Replay from "../../test/replay"; +import Config from "../../config"; +import { goToPreviousWord } from "../helpers/word-navigation"; +import { DeleteInputType } from "../helpers/input-type"; + +export function onDelete(inputType: DeleteInputType): void { + const { realInputValue } = getInputElementValue(); + + const inputBeforeDelete = TestInput.input.current; + + TestInput.input.syncWithInputElement(); + + Replay.addReplayEvent("setLetterIndex", TestInput.input.current.length); + TestInput.setCurrentNotAfk(); + + const beforeDeleteOnlyTabs = /^\t*$/.test(inputBeforeDelete); + const allTabsCorrect = TestWords.words + .getCurrent() + .startsWith(TestInput.input.current); + + //special check for code languages + if ( + Config.language.startsWith("code") && + Config.codeUnindentOnBackspace && + inputBeforeDelete.length > 0 && + beforeDeleteOnlyTabs && + allTabsCorrect + // (TestInput.input.getHistory(TestState.activeWordIndex - 1) !== + // TestWords.words.get(TestState.activeWordIndex - 1) || + // Config.freedomMode) + ) { + setInputElementValue(""); + TestInput.input.syncWithInputElement(); + goToPreviousWord(inputType, true); + } else { + //normal backspace + if (realInputValue === "") { + goToPreviousWord(inputType); + } + } + + TestUI.afterTestDelete(); +} diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts new file mode 100644 index 000000000000..c23a8a496b69 --- /dev/null +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -0,0 +1,303 @@ +import * as TestUI from "../../test/test-ui"; +import * as TestWords from "../../test/test-words"; +import * as TestInput from "../../test/test-input"; +import { + getInputElementValue, + replaceInputElementLastValueChar, + setInputElementValue, + appendToInputElementValue, +} from "../input-element"; +import { + checkIfFailedDueToDifficulty, + checkIfFailedDueToMinBurst, + checkIfFinished, +} from "../helpers/fail-or-finish"; +import { areCharactersVisuallyEqual, isSpace } from "../../utils/strings"; +import * as TestState from "../../test/test-state"; +import * as TestLogic from "../../test/test-logic"; +import { + findSingleActiveFunboxWithFunction, + isFunboxActiveWithProperty, +} from "../../test/funbox/list"; +import * as Replay from "../../test/replay"; +import * as MonkeyPower from "../../elements/monkey-power"; +import Config from "../../config"; +import * as KeymapEvent from "../../observables/keymap-event"; +import * as WeakSpot from "../../test/weak-spot"; +import * as CompositionState from "../../states/composition"; +import { + isCorrectShiftUsed, + getIncorrectShiftsInARow, + incrementIncorrectShiftsInARow, + resetIncorrectShiftsInARow, +} from "../state"; +import * as Notifications from "../../elements/notifications"; +import { goToNextWord } from "../helpers/word-navigation"; +import { onBeforeInsertText } from "./before-insert-text"; +import { + isCharCorrect, + shouldInsertSpaceCharacter, +} from "../helpers/validation"; + +const charOverrides = new Map([ + ["…", "..."], + // ["œ", "oe"], + // ["æ", "ae"], +]); + +type OnInsertTextParams = { + // might need later? + // inputType: SupportedInputType; + // event: Event; + + // timing information + now: number; + // data being inserted + data: string; + // true if called by compositionEnd + isCompositionEnding?: true; + // are we on the last character of a multi character input + lastInMultiIndex?: boolean; +}; + +export async function onInsertText(options: OnInsertTextParams): Promise { + const { now, lastInMultiIndex, isCompositionEnding } = options; + const { inputValue } = getInputElementValue(); + + if (options.data.length > 1) { + // remove the entire data from the input value + // make sure to not call TestInput.input.syncWithInputElement in here + // it will be updated later in the body of onInsertText + setInputElementValue(inputValue.slice(0, -options.data.length)); + for (let i = 0; i < options.data.length; i++) { + const char = options.data[i] as string; + + // then add it one by one + await emulateInsertText({ + ...options, + data: char, + lastInMultiIndex: i === options.data.length - 1, + }); + } + return; + } + + const charOverride = charOverrides.get(options.data); + if ( + charOverride !== undefined && + TestWords.words.getCurrent()[TestInput.input.current.length] !== + options.data + ) { + await onInsertText({ + ...options, + data: charOverride, + }); + return; + } + + // if the character is visually equal, replace it with the target character + // this ensures all future equivalence checks work correctly + let normalizedData: string | null = null; + const targetChar = + TestWords.words.getCurrent()[TestInput.input.current.length]; + if ( + targetChar !== undefined && + areCharactersVisuallyEqual(options.data, targetChar, Config.language) + ) { + replaceInputElementLastValueChar(targetChar); + normalizedData = targetChar; + } + + const data = normalizedData ?? options.data; + + // start if needed + if (!TestState.isActive) { + TestLogic.startTest(now); + } + + // helper consts + const lastInMultiOrSingle = + lastInMultiIndex === true || lastInMultiIndex === undefined; + const testInput = TestInput.input.current; + const currentWord = TestWords.words.getCurrent(); + const wordIndex = TestState.activeWordIndex; + const charIsSpace = isSpace(data); + const charIsNewline = data === "\n"; + const shouldInsertSpace = + shouldInsertSpaceCharacter({ + data, + inputValue: testInput, + targetWord: currentWord, + }) === true; + const correctShiftUsed = + Config.oppositeShiftMode === "off" ? null : isCorrectShiftUsed(); + + // is char correct + const funboxCorrect = findSingleActiveFunboxWithFunction( + "isCharCorrect" + )?.functions.isCharCorrect(data, currentWord[inputValue.length] ?? ""); + const correct = + funboxCorrect ?? + isCharCorrect({ + data, + inputValue: testInput, + targetWord: currentWord, + correctShiftUsed, + }); + + // word navigation check + const noSpaceForce = + isFunboxActiveWithProperty("nospace") && + TestInput.input.current.length === TestWords.words.getCurrent().length; + const shouldGoToNextWord = + ((charIsSpace || charIsNewline) && !shouldInsertSpace) || noSpaceForce; + + // update test input state + if (!charIsSpace || shouldInsertSpace) { + TestInput.input.syncWithInputElement(); + } + + // general per keypress updates + TestInput.setCurrentNotAfk(); + Replay.addReplayEvent(correct ? "correctLetter" : "incorrectLetter", data); + void MonkeyPower.addPower(correct); + TestInput.incrementAccuracy(correct); + WeakSpot.updateScore(data, correct); + TestInput.incrementKeypressCount(); + TestInput.pushKeypressWord(wordIndex); + if (!correct) { + TestInput.incrementKeypressErrors(); + TestInput.pushMissedWord(TestWords.words.getCurrent()); + } + if (Config.keymapMode === "react") { + void KeymapEvent.flash(data, correct); + } + if (testInput.length === 0) { + TestInput.setBurstStart(now); + } + if (!shouldGoToNextWord) { + TestInput.corrected.update(data, correct); + } + + // handing cases where last char needs to be removed + // this is here and not in beforeInsertText because we want to penalize for incorrect spaces + // like accuracy, keypress errors, and missed words + let removeLastChar = false; + let visualInputOverride: string | undefined; + if (Config.stopOnError === "letter" && !correct) { + if (!Config.blindMode) { + visualInputOverride = testInput + data; + } + removeLastChar = true; + } + + if (!isSpace(data) && correctShiftUsed === false) { + removeLastChar = true; + visualInputOverride = undefined; + incrementIncorrectShiftsInARow(); + if (getIncorrectShiftsInARow() >= 5) { + Notifications.add("Opposite shift mode is on.", 0, { + important: true, + customTitle: "Reminder", + }); + } + } else { + resetIncorrectShiftsInARow(); + } + + if (removeLastChar) { + replaceInputElementLastValueChar(""); + TestInput.input.syncWithInputElement(); + } + + // going to next word + let increasedWordIndex: null | boolean = null; + let lastBurst: null | number = null; + if (shouldGoToNextWord) { + const result = await goToNextWord({ + correctInsert: correct, + isCompositionEnding: isCompositionEnding === true, + }); + lastBurst = result.lastBurst; + increasedWordIndex = result.increasedWordIndex; + } + + /* + Probably a good place to explain what the heck is going on with all these space related variables: + - spaceOrNewLine: did the user input a space or a new line? + - shouldInsertSpace: should space be treated as a character, or should it move us to the next word + monkeytype doesnt actually have space characters in words, so we need this distinction + and also moving to the next word might get blocked by things like stop on error + - shouldGoToNextWord: IF input is space and we DONT insert a space CHARACTER, we will TRY to go to the next word + - increasedWordIndex: the only reason this is here because on the last word we dont move to the next word + */ + + //this COULD be the next word because we are awaiting goToNextWord + const nextWord = TestWords.words.getCurrent(); + const doesNextWordHaveTab = /^\t+/.test(nextWord); + const isCurrentCharTab = nextWord[TestInput.input.current.length] === "\t"; + + //code mode - auto insert tabs + if ( + Config.language.startsWith("code") && + correct && + doesNextWordHaveTab && + isCurrentCharTab + ) { + setTimeout(() => { + void emulateInsertText({ data: "\t", now }); + }, 0); + } + + if (!CompositionState.getComposing() && lastInMultiOrSingle) { + if ( + checkIfFailedDueToDifficulty({ + testInputWithData: testInput + data, + correct, + spaceOrNewline: charIsSpace || charIsNewline, + }) + ) { + TestLogic.fail("difficulty"); + } else if ( + increasedWordIndex && + checkIfFailedDueToMinBurst({ + testInputWithData: testInput + data, + currentWord, + lastBurst, + }) + ) { + TestLogic.fail("min burst"); + } else if ( + checkIfFinished({ + shouldGoToNextWord, + testInputWithData: testInput + data, + currentWord, + allWordsTyped: wordIndex >= TestWords.words.length - 1, + allWordsGenerated: TestLogic.areAllTestWordsGenerated(), + }) + ) { + void TestLogic.finish(); + } + } + + if (lastInMultiOrSingle) { + TestUI.afterTestTextInput(correct, increasedWordIndex, visualInputOverride); + } +} + +export async function emulateInsertText( + options: OnInsertTextParams +): Promise { + const inputStopped = onBeforeInsertText(options.data); + + if (inputStopped) { + return; + } + + // default is prevented so we need to manually update the input value. + // remember to not call TestInput.input.syncWithInputElement in here + // it will be called later be updated in onInsertText + appendToInputElementValue(options.data); + + await onInsertText(options); +} diff --git a/frontend/src/ts/input/handlers/keydown.ts b/frontend/src/ts/input/handlers/keydown.ts new file mode 100644 index 000000000000..8497c9a057ac --- /dev/null +++ b/frontend/src/ts/input/handlers/keydown.ts @@ -0,0 +1,212 @@ +import Config from "../../config"; +import * as TestInput from "../../test/test-input"; +import * as TestLogic from "../../test/test-logic"; +import { getCharFromEvent } from "../../test/layout-emulator"; +import * as Monkey from "../../test/monkey"; +import { emulateInsertText } from "./insert-text"; +import * as TestState from "../../test/test-state"; +import * as TestWords from "../../test/test-words"; +import * as JSONData from "../../utils/json-data"; +import * as Notifications from "../../elements/notifications"; +import * as KeyConverter from "../../utils/key-converter"; +import * as ShiftTracker from "../../test/shift-tracker"; +import * as CompositionState from "../../states/composition"; +import { canQuickRestart } from "../../utils/quick-restart"; +import * as CustomText from "../../test/custom-text"; +import * as CustomTextState from "../../states/custom-text-name"; +import { + getLastBailoutAttempt, + setCorrectShiftUsed, + setLastBailoutAttempt, +} from "../state"; +import { + getActiveFunboxesWithFunction, + getActiveFunboxNames, +} from "../../test/funbox/list"; + +export async function handleTab(e: KeyboardEvent, now: number): Promise { + if (Config.quickRestart === "tab") { + e.preventDefault(); + if ((TestWords.hasTab && e.shiftKey) || !TestWords.hasTab) { + TestLogic.restart(); + return; + } + } + if (TestWords.hasTab) { + await emulateInsertText({ data: "\t", now }); + e.preventDefault(); + return; + } +} + +export async function handleEnter( + e: KeyboardEvent, + now: number +): Promise { + if (e.shiftKey) { + if (Config.mode === "zen") { + void TestLogic.finish(); + return; + } else if ( + !canQuickRestart( + Config.mode, + Config.words, + Config.time, + CustomText.getData(), + CustomTextState.isCustomTextLong() ?? false + ) + ) { + const delay = Date.now() - getLastBailoutAttempt(); + if (getLastBailoutAttempt() === -1 || delay > 200) { + setLastBailoutAttempt(Date.now()); + if (delay >= 5000) { + Notifications.add( + "Please double tap shift+enter to confirm bail out", + 0, + { + important: true, + duration: 5, + } + ); + } + return; + } else { + TestState.setBailedOut(true); + void TestLogic.finish(); + return; + } + } + } + + if (Config.quickRestart === "enter") { + e.preventDefault(); + if ((TestWords.hasNewline && e.shiftKey) || !TestWords.hasNewline) { + TestLogic.restart(); + return; + } + } + if ( + TestWords.hasNewline || + (Config.mode === "zen" && !CompositionState.getComposing()) + ) { + await emulateInsertText({ data: "\n", now }); + e.preventDefault(); + return; + } +} + +export async function handleOppositeShift(event: KeyboardEvent): Promise { + if ( + Config.oppositeShiftMode === "keymap" && + Config.keymapLayout !== "overrideSync" + ) { + let keymapLayout = await JSONData.getLayout(Config.keymapLayout).catch( + () => undefined + ); + if (keymapLayout === undefined) { + Notifications.add("Failed to load keymap layout", -1); + + return; + } + + const funbox = getActiveFunboxNames().includes("layout_mirror"); + if (funbox) { + keymapLayout = KeyConverter.mirrorLayoutKeys(keymapLayout); + } + + const keycode = KeyConverter.layoutKeyToKeycode(event.key, keymapLayout); + + setCorrectShiftUsed( + keycode === undefined ? true : ShiftTracker.isUsingOppositeShift(keycode) + ); + } else { + setCorrectShiftUsed( + ShiftTracker.isUsingOppositeShift(event.code as KeyConverter.Keycode) + ); + } +} + +async function handleFunboxes( + event: KeyboardEvent, + now: number +): Promise { + for (const fb of getActiveFunboxesWithFunction("handleKeydown")) { + void fb.functions.handleKeydown(event); + } + + for (const fb of getActiveFunboxesWithFunction("getEmulatedChar")) { + const emulatedChar = fb.functions.getEmulatedChar(event); + if (emulatedChar !== null) { + await emulateInsertText({ data: emulatedChar, now }); + return true; + } + } + return false; +} + +export async function onKeydown(event: KeyboardEvent): Promise { + console.debug("wordsInput event keydown", { + event, + key: event.key, + code: event.code, + }); + + const now = performance.now(); + TestInput.recordKeydownTime(now, event); + + // allow arrows in arrows funbox + const arrowsActive = Config.funbox.includes("arrows"); + if ( + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" || + (event.key.startsWith("Arrow") && !arrowsActive) + ) { + event.preventDefault(); + return; + } + + if (Config.oppositeShiftMode !== "off") { + await handleOppositeShift(event); + } + + const prevent = await handleFunboxes(event, now); + if (prevent) { + event.preventDefault(); + return; + } + + if (Config.layout !== "default") { + const emulatedChar = await getCharFromEvent(event); + if (emulatedChar !== null) { + await emulateInsertText({ data: emulatedChar, now }); + event.preventDefault(); + return; + } + } + + if (!event.repeat) { + //delaying because type() is called before show() + // meaning the first keypress of the test is not animated + setTimeout(() => { + Monkey.type(event); + }, 0); + } + + if (event.key === "Tab") { + await handleTab(event, now); + return; + } + + if (event.key === "Enter") { + await handleEnter(event, now); + return; + } + + if (event.key === "Escape" && Config.quickRestart === "esc") { + event.preventDefault(); + TestLogic.restart(); + return; + } +} diff --git a/frontend/src/ts/input/handlers/keyup.ts b/frontend/src/ts/input/handlers/keyup.ts new file mode 100644 index 000000000000..995331c8265f --- /dev/null +++ b/frontend/src/ts/input/handlers/keyup.ts @@ -0,0 +1,25 @@ +import Config from "../../config"; +import * as TestInput from "../../test/test-input"; +import * as Monkey from "../../test/monkey"; + +export async function onKeyup(event: KeyboardEvent): Promise { + const now = performance.now(); + TestInput.recordKeyupTime(now, event); + + // allow arrows in arrows funbox + const arrowsActive = Config.funbox.includes("arrows"); + if ( + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" || + (event.key.startsWith("Arrow") && !arrowsActive) + ) { + event.preventDefault(); + return; + } + + setTimeout(() => { + Monkey.stop(event); + }, 0); +} diff --git a/frontend/src/ts/input/helpers/fail-or-finish.ts b/frontend/src/ts/input/helpers/fail-or-finish.ts new file mode 100644 index 000000000000..4b89e0768d5c --- /dev/null +++ b/frontend/src/ts/input/helpers/fail-or-finish.ts @@ -0,0 +1,104 @@ +import Config from "../../config"; +import { whorf } from "../../utils/misc"; + +/** + * Check if the test should fail due to minimum burst settings + * @param options - Options object + * @param options.testInputWithData - Current test input result (after adding data) + * @param options.currentWord - Current target word + * @param options.lastBurst - Burst speed in WPM + */ +export function checkIfFailedDueToMinBurst(options: { + testInputWithData: string; + currentWord: string; + lastBurst: number | null; +}): boolean { + const { testInputWithData, currentWord, lastBurst } = options; + if (Config.minBurst !== "off" && lastBurst !== null) { + let wordLength: number; + if (Config.mode === "zen") { + wordLength = testInputWithData.length; + } else { + wordLength = currentWord.length; + } + + const flex: number = whorf(Config.minBurstCustomSpeed, wordLength); + if ( + (Config.minBurst === "fixed" && lastBurst < Config.minBurstCustomSpeed) || + (Config.minBurst === "flex" && lastBurst < flex) + ) { + return true; + } + } + return false; +} + +/** + * Check if the test should fail due to difficulty settings + * @param options - Options object + * @param options.testInputWithData - Current test input result (after adding data) + * @param options.correct - Was the last input correct + * @param options.spaceOrNewline - Is the input a space or newline + */ +export function checkIfFailedDueToDifficulty(options: { + testInputWithData: string; + correct: boolean; + spaceOrNewline: boolean; +}): boolean { + const { testInputWithData, correct, spaceOrNewline } = options; + // Using space or newline instead of shouldInsertSpace or increasedWordIndex + // because we want expert mode to fail no matter if confidence or stop on error is on + + if (Config.mode === "zen") return false; + + const shouldFailDueToExpert = + Config.difficulty === "expert" && + !correct && + spaceOrNewline && + testInputWithData.length > 1; + + const shouldFailDueToMaster = Config.difficulty === "master" && !correct; + + if (shouldFailDueToExpert || shouldFailDueToMaster) { + return true; + } + return false; +} + +/** + * Determines if the test should finish + * @param options - Options object + * @param options.shouldGoToNextWord - Should go to next word + * @param options.testInputWithData - Current test input result (after adding data) + * @param options.currentWord - Current target word + * @param options.allWordsTyped - Have all words been typed + * @returns Boolean if test should finish + */ +export function checkIfFinished(options: { + shouldGoToNextWord: boolean; + testInputWithData: string; + currentWord: string; + allWordsTyped: boolean; + allWordsGenerated: boolean; +}): boolean { + const { + shouldGoToNextWord, + testInputWithData, + currentWord, + allWordsTyped, + allWordsGenerated, + } = options; + const wordIsCorrect = testInputWithData === currentWord; + const shouldQuickEnd = + Config.quickEnd && + currentWord.length === testInputWithData.length && + Config.stopOnError === "off"; + if ( + allWordsTyped && + allWordsGenerated && + (wordIsCorrect || shouldQuickEnd || shouldGoToNextWord) + ) { + return true; + } + return false; +} diff --git a/frontend/src/ts/input/helpers/input-type.ts b/frontend/src/ts/input/helpers/input-type.ts new file mode 100644 index 000000000000..e7762ae982f0 --- /dev/null +++ b/frontend/src/ts/input/helpers/input-type.ts @@ -0,0 +1,22 @@ +export type InsertInputType = + | "insertText" + | "insertCompositionText" + | "insertLineBreak"; + +export type DeleteInputType = "deleteWordBackward" | "deleteContentBackward"; + +export type SupportedInputType = InsertInputType | DeleteInputType; + +const SUPPORTED_INPUT_TYPES: Set = new Set([ + "insertText", + "insertCompositionText", + "insertLineBreak", + "deleteWordBackward", + "deleteContentBackward", +]); + +export function isSupportedInputType( + inputType: string +): inputType is SupportedInputType { + return SUPPORTED_INPUT_TYPES.has(inputType as SupportedInputType); +} diff --git a/frontend/src/ts/input/helpers/validation.ts b/frontend/src/ts/input/helpers/validation.ts new file mode 100644 index 000000000000..4a2398a8dcd4 --- /dev/null +++ b/frontend/src/ts/input/helpers/validation.ts @@ -0,0 +1,77 @@ +import Config from "../../config"; +import { isSpace } from "../../utils/strings"; + +/** + * Check if the input data is correct + * @param options - Options object + * @param options.data - Input data + * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.targetWord - Target word + * @param options.correctShiftUsed - Whether the correct shift state was used. Null means disabled + */ +export function isCharCorrect(options: { + data: string; + inputValue: string; + targetWord: string; + correctShiftUsed: boolean | null; //null means disabled +}): boolean { + const { data, inputValue, targetWord, correctShiftUsed } = options; + + if (Config.mode === "zen") return true; + + if (correctShiftUsed === false) return false; + + if (data === undefined) { + throw new Error("Failed to check if char is correct - data is undefined"); + } + + if (data === " ") { + return inputValue === targetWord; + } + + const targetChar = targetWord[inputValue.length]; + + if (targetChar === undefined) { + return false; + } + + if (data === targetChar) { + return true; + } + + return false; +} + +/** + * Determines if a space character should be inserted as a character, or act + * as a "control character" (moving to the next word) + * @param options - Options object + * @param options.data - Input data + * @param options.inputValue - Current input value (use TestInput.input.current, not input element value) + * @param options.targetWord - Target word + * @returns Boolean if data is space, null if not + */ +export function shouldInsertSpaceCharacter(options: { + data: string; + inputValue: string; + targetWord: string; +}): boolean | null { + const { data, inputValue, targetWord } = options; + if (!isSpace(data)) { + return null; + } + if (Config.mode === "zen") { + return false; + } + const correctSoFar = (targetWord + " ").startsWith(inputValue + " "); + const stopOnErrorLetterAndIncorrect = + Config.stopOnError === "letter" && !correctSoFar; + const stopOnErrorWordAndIncorrect = + Config.stopOnError === "word" && !correctSoFar; + const strictSpace = + inputValue.length === 0 && + (Config.strictSpace || Config.difficulty !== "normal"); + return ( + stopOnErrorLetterAndIncorrect || stopOnErrorWordAndIncorrect || strictSpace + ); +} diff --git a/frontend/src/ts/input/helpers/word-navigation.ts b/frontend/src/ts/input/helpers/word-navigation.ts new file mode 100644 index 000000000000..269d1b5d2757 --- /dev/null +++ b/frontend/src/ts/input/helpers/word-navigation.ts @@ -0,0 +1,126 @@ +import Config from "../../config"; +import * as TestInput from "../../test/test-input"; +import * as TestUI from "../../test/test-ui"; +import * as PaceCaret from "../../test/pace-caret"; +import * as TestState from "../../test/test-state"; +import * as TestLogic from "../../test/test-logic"; +import * as TestWords from "../../test/test-words"; +import { + getActiveFunboxesWithFunction, + isFunboxActiveWithProperty, +} from "../../test/funbox/list"; +import * as TestStats from "../../test/test-stats"; +import * as Replay from "../../test/replay"; +import * as Funbox from "../../test/funbox/funbox"; +import * as Loader from "../../elements/loader"; +import { setInputElementValue } from "../input-element"; +import { setAwaitingNextWord } from "../state"; +import { DeleteInputType } from "./input-type"; + +type GoToNextWordParams = { + correctInsert: boolean; + // this is used to tell test ui to update the word before moving to the next word (in case of a composition that ends with a space) + isCompositionEnding: boolean; +}; + +type GoToNextWordReturn = { + increasedWordIndex: boolean; + lastBurst: number; +}; + +export async function goToNextWord({ + correctInsert, + isCompositionEnding, +}: GoToNextWordParams): Promise { + const ret = { + increasedWordIndex: false, + lastBurst: 0, + }; + + TestUI.beforeTestWordChange("forward", correctInsert, isCompositionEnding); + + if (correctInsert) { + Replay.addReplayEvent("submitCorrectWord"); + } else { + Replay.addReplayEvent("submitErrorWord"); + } + + for (const fb of getActiveFunboxesWithFunction("handleSpace")) { + fb.functions.handleSpace(); + } + + //burst calculation and fail + const burst: number = TestStats.calculateBurst(); + TestInput.pushBurstToHistory(burst); + ret.lastBurst = burst; + + PaceCaret.handleSpace(correctInsert, TestWords.words.getCurrent()); + + Funbox.toggleScript(TestWords.words.get(TestState.activeWordIndex + 1)); + + TestInput.input.pushHistory(); + TestInput.corrected.pushHistory(); + + const lastWord = TestState.activeWordIndex >= TestWords.words.length - 1; + if (lastWord) { + setAwaitingNextWord(true); + Loader.show(); + await TestLogic.addWord(); + Loader.hide(); + setAwaitingNextWord(false); + } else { + await TestLogic.addWord(); + } + + if ( + TestState.activeWordIndex < TestWords.words.length - 1 || + Config.mode === "zen" + ) { + ret.increasedWordIndex = true; + TestState.increaseActiveWordIndex(); + } + + setInputElementValue(""); + TestInput.input.syncWithInputElement(); + void TestUI.afterTestWordChange("forward"); + + return ret; +} + +export function goToPreviousWord( + inputType: DeleteInputType, + forceUpdateActiveWordLetters = false +): void { + if (TestState.activeWordIndex === 0) { + setInputElementValue(""); + TestInput.input.syncWithInputElement(); + return; + } + + TestUI.beforeTestWordChange("back", null, forceUpdateActiveWordLetters); + + Replay.addReplayEvent("backWord"); + + const word = TestInput.input.popHistory(); + TestState.decreaseActiveWordIndex(); + TestInput.corrected.popHistory(); + + Funbox.toggleScript(TestWords.words.get(TestState.activeWordIndex)); + + const nospaceEnabled = isFunboxActiveWithProperty("nospace"); + + if (inputType === "deleteWordBackward") { + setInputElementValue(""); + } else if (inputType === "deleteContentBackward") { + if (nospaceEnabled) { + setInputElementValue(word.slice(0, -1)); + } else if (word.endsWith("\n")) { + setInputElementValue(word.slice(0, -1)); + } else { + setInputElementValue(word); + } + } + TestInput.input.syncWithInputElement(); + + void TestUI.afterTestWordChange("back"); +} diff --git a/frontend/src/ts/input/input-element.ts b/frontend/src/ts/input/input-element.ts new file mode 100644 index 000000000000..44b1de1edab0 --- /dev/null +++ b/frontend/src/ts/input/input-element.ts @@ -0,0 +1,50 @@ +const el = document.querySelector("#wordsInput") as HTMLInputElement; + +if (el === null) { + throw new Error("Words input element not found"); +} + +export function getInputElement(): HTMLInputElement { + return el; +} + +export function setInputElementValue(value: string): void { + el.value = " " + value; +} + +export function appendToInputElementValue(value: string): void { + el.value += value; +} + +export function getInputElementValue(): { + inputValue: string; + realInputValue: string; +} { + return { + inputValue: el.value.slice(1), + realInputValue: el.value, + }; +} + +export function moveInputElementCaretToTheEnd(): void { + el.setSelectionRange(el.value.length, el.value.length); +} + +export function replaceInputElementLastValueChar(char: string): void { + const { inputValue } = getInputElementValue(); + setInputElementValue(inputValue.slice(0, -1) + char); +} + +export function isInputElementFocused(): boolean { + return document.activeElement === el; +} + +export function focusInputElement(preventScroll = false): void { + el.focus({ + preventScroll, + }); +} + +export function blurInputElement(): void { + el.blur(); +} diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts new file mode 100644 index 000000000000..d3961e77d508 --- /dev/null +++ b/frontend/src/ts/input/listeners/composition.ts @@ -0,0 +1,53 @@ +import { getInputElement } from "../input-element"; +import * as CompositionState from "../../states/composition"; +import * as TestState from "../../test/test-state"; +import * as TestLogic from "../../test/test-logic"; +import { setLastInsertCompositionTextData } from "../state"; +import * as CompositionDisplay from "../../elements/composition-display"; +import { onInsertText } from "../handlers/insert-text"; + +const inputEl = getInputElement(); + +inputEl.addEventListener("compositionstart", (event) => { + console.debug("wordsInput event compositionstart", { + event, + data: event.data, + }); + + CompositionState.setComposing(true); + CompositionState.setData(""); + setLastInsertCompositionTextData(""); + if (!TestState.isActive) { + TestLogic.startTest(performance.now()); + } +}); + +inputEl.addEventListener("compositionupdate", (event) => { + console.debug("wordsInput event compositionupdate", { + event, + data: event.data, + }); + + CompositionState.setData(event.data); + CompositionDisplay.update(event.data); +}); + +inputEl.addEventListener("compositionend", async (event) => { + console.debug("wordsInput event compositionend", { event, data: event.data }); + + if (TestState.testRestarting) return; + CompositionState.setComposing(false); + CompositionState.setData(""); + CompositionDisplay.update(""); + setLastInsertCompositionTextData(""); + + const now = performance.now(); + + if (event.data !== "") { + await onInsertText({ + data: event.data, + now, + isCompositionEnding: true, + }); + } +}); diff --git a/frontend/src/ts/input/listeners/index.ts b/frontend/src/ts/input/listeners/index.ts new file mode 100644 index 000000000000..095c9852043b --- /dev/null +++ b/frontend/src/ts/input/listeners/index.ts @@ -0,0 +1,4 @@ +import "./composition"; +import "./key"; +import "./input"; +import "./misc"; diff --git a/frontend/src/ts/input/listeners/input.ts b/frontend/src/ts/input/listeners/input.ts new file mode 100644 index 000000000000..cef71a12a425 --- /dev/null +++ b/frontend/src/ts/input/listeners/input.ts @@ -0,0 +1,117 @@ +import { onDelete } from "../handlers/delete"; +import { onInsertText } from "../handlers/insert-text"; +import { + isSupportedInputType, + SupportedInputType, +} from "../helpers/input-type"; +import { getInputElement } from "../input-element"; +import { + getLastInsertCompositionTextData, + setLastInsertCompositionTextData, +} from "../state"; +import * as TestUI from "../../test/test-ui"; +import { onBeforeInsertText } from "../handlers/before-insert-text"; +import { onBeforeDelete } from "../handlers/before-delete"; + +const inputEl = getInputElement(); + +inputEl.addEventListener("beforeinput", async (event) => { + if (!(event instanceof InputEvent)) { + //beforeinput is typed as inputevent but input is not? + //@ts-expect-error just doing this as a sanity check + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + event.preventDefault(); + return; + } + console.debug("wordsInput event beforeinput", { + event, + inputType: event.inputType, + data: event.data, + value: (event.target as HTMLInputElement).value, + }); + + if (!isSupportedInputType(event.inputType)) { + event.preventDefault(); + return; + } + + const inputType = event.inputType; + + if ( + (inputType === "insertText" && event.data !== null) || + inputType === "insertLineBreak" + ) { + let data = event.data as string; + if (inputType === "insertLineBreak") { + // insertLineBreak events dont have data set + data = "\n"; + } + + const preventDefault = onBeforeInsertText(data); + if (preventDefault) { + event.preventDefault(); + } + } else if ( + inputType === "deleteWordBackward" || + inputType === "deleteContentBackward" + ) { + onBeforeDelete(event); + } else if (inputType === "insertCompositionText") { + // firefox fires this extra event which we dont want to handle + if (!event.isComposing) { + event.preventDefault(); + } + } else { + throw new Error("Unhandled beforeinput type: " + inputType); + } +}); + +inputEl.addEventListener("input", async (event) => { + if (!(event instanceof InputEvent)) { + //since the listener is on an input element, this should never trigger + //but its here to narrow the type of "event" + event.preventDefault(); + return; + } + console.debug("wordsInput event input", { + event, + inputType: event.inputType, + data: event.data, + value: (event.target as HTMLInputElement).value, + }); + + const now = performance.now(); + + // this is ok to cast because we are preventing default + // in the input listener for unsupported input types + const inputType = event.inputType as SupportedInputType; + + if ( + (inputType === "insertText" && event.data !== null) || + inputType === "insertLineBreak" + ) { + let data = event.data as string; + if (inputType === "insertLineBreak") { + // insertLineBreak events dont have data set + data = "\n"; + } + + await onInsertText({ + data, + now, + }); + } else if ( + inputType === "deleteWordBackward" || + inputType === "deleteContentBackward" + ) { + onDelete(inputType); + } else if (inputType === "insertCompositionText") { + // in case the data is the same as the last one, just ignore it + if (getLastInsertCompositionTextData() !== event.data) { + setLastInsertCompositionTextData(event.data ?? ""); + TestUI.afterTestCompositionUpdate(); + } + } else { + throw new Error("Unhandled input type: " + inputType); + } +}); diff --git a/frontend/src/ts/input/listeners/key.ts b/frontend/src/ts/input/listeners/key.ts new file mode 100644 index 000000000000..6409c00fd1d8 --- /dev/null +++ b/frontend/src/ts/input/listeners/key.ts @@ -0,0 +1,25 @@ +import { getInputElement } from "../input-element"; +import { onKeyup } from "../handlers/keyup"; +import { onKeydown } from "../handlers/keydown"; + +const inputEl = getInputElement(); + +inputEl.addEventListener("keyup", async (event) => { + console.debug("wordsInput event keyup", { + event, + key: event.key, + code: event.code, + }); + + await onKeyup(event); +}); + +inputEl.addEventListener("keydown", async (event) => { + console.debug("wordsInput event keydown", { + event, + key: event.key, + code: event.code, + }); + + await onKeydown(event); +}); diff --git a/frontend/src/ts/input/listeners/misc.ts b/frontend/src/ts/input/listeners/misc.ts new file mode 100644 index 000000000000..10e985070c4e --- /dev/null +++ b/frontend/src/ts/input/listeners/misc.ts @@ -0,0 +1,40 @@ +import { + getInputElement, + moveInputElementCaretToTheEnd, +} from "../input-element"; + +const inputEl = getInputElement(); + +inputEl.addEventListener("focus", () => { + moveInputElementCaretToTheEnd(); +}); + +inputEl.addEventListener("copy paste", (event) => { + event.preventDefault(); +}); + +//this might not do anything +inputEl.addEventListener("select selectstart", (event) => { + event.preventDefault(); +}); + +inputEl.addEventListener("selectionchange", (event) => { + const selection = window.getSelection(); + console.debug("wordsInput event selectionchange", { + event, + selection: selection?.toString(), + isCollapsed: selection?.isCollapsed, + selectionStart: (event.target as HTMLInputElement).selectionStart, + selectionEnd: (event.target as HTMLInputElement).selectionEnd, + }); + const el = event.target; + if (el === null || !(el instanceof HTMLInputElement)) { + return; + } + + const hasSelectedText = el.selectionStart !== el.selectionEnd; + const isCursorAtEnd = el.selectionStart === el.value.length; + if (hasSelectedText || !isCursorAtEnd) { + moveInputElementCaretToTheEnd(); + } +}); diff --git a/frontend/src/ts/input/state.ts b/frontend/src/ts/input/state.ts new file mode 100644 index 000000000000..a8ad7a179063 --- /dev/null +++ b/frontend/src/ts/input/state.ts @@ -0,0 +1,53 @@ +let correctShiftUsed = true; +let incorrectShiftsInARow = 0; +let awaitingNextWord = false; +let lastBailoutAttempt = -1; +let lastInsertCompositionTextData = ""; + +export function isCorrectShiftUsed(): boolean { + return correctShiftUsed; +} + +export function setCorrectShiftUsed(value: boolean): void { + correctShiftUsed = value; +} + +export function getIncorrectShiftsInARow(): number { + return incorrectShiftsInARow; +} + +export function setIncorrectShiftsInARow(value: number): void { + incorrectShiftsInARow = value; +} + +export function incrementIncorrectShiftsInARow(): void { + incorrectShiftsInARow++; +} + +export function resetIncorrectShiftsInARow(): void { + incorrectShiftsInARow = 0; +} + +export function isAwaitingNextWord(): boolean { + return awaitingNextWord; +} + +export function setAwaitingNextWord(value: boolean): void { + awaitingNextWord = value; +} + +export function getLastBailoutAttempt(): number { + return lastBailoutAttempt; +} + +export function setLastBailoutAttempt(value: number): void { + lastBailoutAttempt = value; +} + +export function getLastInsertCompositionTextData(): string { + return lastInsertCompositionTextData; +} + +export function setLastInsertCompositionTextData(value: string): void { + lastInsertCompositionTextData = value; +} diff --git a/frontend/src/ts/modals/dev-options.ts b/frontend/src/ts/modals/dev-options.ts index 5aa4bcb3c1c7..dc34a0627b53 100644 --- a/frontend/src/ts/modals/dev-options.ts +++ b/frontend/src/ts/modals/dev-options.ts @@ -8,6 +8,7 @@ import * as Loader from "../elements/loader"; import { update } from "../elements/xp-bar"; import { toggleUserFakeChartData } from "../test/result"; import { toggleCaretDebug } from "../utils/caret"; +import { getInputElement } from "../input/input-element"; let mediaQueryDebugLevel = 0; @@ -49,7 +50,7 @@ async function setup(modalEl: HTMLElement): Promise { modalEl .querySelector(".showRealWordsInput") ?.addEventListener("click", () => { - $("#wordsInput").css("opacity", "1"); + getInputElement().style.opacity = "1"; void modal.hide(); }); modalEl.querySelector(".quickLogin")?.addEventListener("click", () => { diff --git a/frontend/src/ts/modals/share-custom-theme.ts b/frontend/src/ts/modals/share-custom-theme.ts index f05129eab06b..657cc49692ea 100644 --- a/frontend/src/ts/modals/share-custom-theme.ts +++ b/frontend/src/ts/modals/share-custom-theme.ts @@ -30,9 +30,9 @@ async function generateUrl(): Promise { } = { c: ThemeController.colorVars.map( (color) => - $( - `.pageSettings .customTheme .tabContent.customTheme #${color}[type='color']` - ).attr("value") as string + $(`.pageSettings .tabContent.customTheme #${color}[type='color']`).attr( + "value" + ) as string ), }; diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 74b74103e2f4..3cc424b10cc5 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -1133,7 +1133,7 @@ list.updateCustomTheme = new SimpleModal({ for (const color of ThemeController.colorVars) { newColors.push( $( - `.pageSettings .customTheme .tabContent.customTheme #${color}[type='color']` + `.pageSettings .tabContent.customTheme #${color}[type='color']` ).attr("value") as string ); } diff --git a/frontend/src/ts/pages/test.ts b/frontend/src/ts/pages/test.ts index bd6cf32aa5b0..37b37a3acf2d 100644 --- a/frontend/src/ts/pages/test.ts +++ b/frontend/src/ts/pages/test.ts @@ -8,13 +8,14 @@ import * as ModesNotice from "../elements/modes-notice"; import * as Keymap from "../elements/keymap"; import * as TestConfig from "../test/test-config"; import * as ScrollToTop from "../elements/scroll-to-top"; +import { blurInputElement } from "../input/input-element"; export const page = new Page({ id: "test", element: $(".page.pageTest"), path: "/", beforeHide: async (): Promise => { - $("#wordsInput").trigger("focusout"); + blurInputElement(); }, afterHide: async (): Promise => { ManualRestart.set(); diff --git a/frontend/src/ts/states/composition.ts b/frontend/src/ts/states/composition.ts index 5cc1006562dd..af4f7b579af5 100644 --- a/frontend/src/ts/states/composition.ts +++ b/frontend/src/ts/states/composition.ts @@ -1,20 +1,20 @@ const compositionState = { composing: false, - startPos: -1, + data: "", }; export function getComposing(): boolean { return compositionState.composing; } -export function getStartPos(): number { - return compositionState.startPos; -} - export function setComposing(isComposing: boolean): void { compositionState.composing = isComposing; } -export function setStartPos(pos: number): void { - compositionState.startPos = pos; +export function setData(data: string): void { + compositionState.data = data; +} + +export function getData(): string { + return compositionState.data; } diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index 60164f8c8307..983016243185 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -3,6 +3,7 @@ import * as TestInput from "./test-input"; import * as TestState from "../test/test-state"; import { subscribe } from "../observables/config-event"; import { Caret } from "../utils/caret"; +import * as CompositionState from "../states/composition"; export function stopAnimation(): void { caret.stopBlinking(); @@ -31,7 +32,8 @@ export function resetPosition(): void { export function updatePosition(noAnim = false): void { caret.goTo({ wordIndex: TestState.activeWordIndex, - letterIndex: TestInput.input.current.length, + letterIndex: + TestInput.input.current.length + CompositionState.getData().length, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, 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 8175981098ee..2b66718afc86 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -36,14 +36,9 @@ export type FunboxFunctions = { toggleScript?: (params: string[]) => void; pullSection?: (language?: Language) => Promise; handleSpace?: () => void; - handleChar?: (char: string) => string; + getEmulatedChar?: (event: KeyboardEvent) => string | null; isCharCorrect?: (char: string, originalChar: string) => boolean; - preventDefaultEvent?: ( - event: JQuery.KeyDownEvent - ) => Promise; - handleKeydown?: ( - event: JQuery.KeyDownEvent - ) => Promise; + handleKeydown?: (event: KeyboardEvent) => Promise; getResultContent?: () => string; start?: () => void; restart?: () => void; @@ -51,9 +46,7 @@ export type FunboxFunctions = { getWordsFrequencyMode?: () => FunboxWordsFrequency; }; -async function readAheadHandleKeydown( - event: JQuery.KeyDownEvent -): Promise { +async function readAheadHandleKeydown(event: KeyboardEvent): Promise { const inputCurrentChar = (TestInput.input.current ?? "").slice(-1); const wordCurrentChar = TestWords.words .getCurrent() @@ -205,11 +198,11 @@ const list: Partial> = { rememberSettings(): void { save("numbers", Config.numbers, UpdateConfig.setNumbers); }, - handleChar(char: string): string { - if (char === "\n") { + getEmulatedChar(event: KeyboardEvent): string | null { + if (event.key === "Enter") { return " "; } - return char; + return null; }, }, simon_says: { @@ -246,20 +239,21 @@ const list: Partial> = { UpdateConfig.setHighlightMode ); }, - handleChar(char: string): string { - if (char === "a" || char === "ArrowLeft" || char === "j") { + getEmulatedChar(event: KeyboardEvent): string | null { + const ekey = event.key; + if (ekey === "a" || ekey === "ArrowLeft" || ekey === "j") { return "←"; } - if (char === "s" || char === "ArrowDown" || char === "k") { + if (ekey === "s" || ekey === "ArrowDown" || ekey === "k") { return "↓"; } - if (char === "w" || char === "ArrowUp" || char === "i") { + if (ekey === "w" || ekey === "ArrowUp" || ekey === "i") { return "↑"; } - if (char === "d" || char === "ArrowRight" || char === "l") { + if (ekey === "d" || ekey === "ArrowRight" || ekey === "l") { return "→"; } - return char; + return null; }, isCharCorrect(char: string, originalChar: string): boolean { if ( @@ -288,11 +282,6 @@ const list: Partial> = { } return false; }, - async preventDefaultEvent(event: JQuery.KeyDownEvent): Promise { - return ["ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"].includes( - event.key - ); - }, getWordHtml(char: string, letterTag?: boolean): string { let retval = ""; if (char === "↑") { diff --git a/frontend/src/ts/test/layout-emulator.ts b/frontend/src/ts/test/layout-emulator.ts index 5a5462cfc633..f784065fc769 100644 --- a/frontend/src/ts/test/layout-emulator.ts +++ b/frontend/src/ts/test/layout-emulator.ts @@ -11,10 +11,10 @@ let isAltGrPressed = false; const isPunctuationPattern = /\p{P}/u; export async function getCharFromEvent( - event: JQuery.KeyDownEvent | JQuery.KeyUpEvent + event: JQuery.KeyDownEvent | JQuery.KeyUpEvent | KeyboardEvent ): Promise { function emulatedLayoutGetVariant( - event: JQuery.KeyDownEvent | JQuery.KeyUpEvent, + event: JQuery.KeyDownEvent | JQuery.KeyUpEvent | KeyboardEvent, keyVariants: string[] ): string | undefined { let isCapitalized = event.shiftKey; diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index 549cf0eb9c21..8b20472638c5 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -74,7 +74,7 @@ export function updateFastOpacity(num: number): void { $("#monkey").css({ animationDuration: animDuration + "s" }); } -export function type(event: JQuery.KeyDownEvent): void { +export function type(event: JQuery.KeyDownEvent | KeyboardEvent): void { if (!Config.monkey) return; const { leftSide, rightSide } = KeyConverter.keycodeToKeyboardSide( @@ -112,7 +112,7 @@ export function type(event: JQuery.KeyDownEvent): void { update(); } -export function stop(event: JQuery.KeyUpEvent): void { +export function stop(event: JQuery.KeyUpEvent | KeyboardEvent): void { if (!Config.monkey) return; const { leftSide, rightSide } = KeyConverter.keycodeToKeyboardSide( diff --git a/frontend/src/ts/test/out-of-focus.ts b/frontend/src/ts/test/out-of-focus.ts index 33c9ac35f054..8d51d4b876cb 100644 --- a/frontend/src/ts/test/out-of-focus.ts +++ b/frontend/src/ts/test/out-of-focus.ts @@ -3,7 +3,7 @@ import * as Misc from "../utils/misc"; const outOfFocusTimeouts: (number | NodeJS.Timeout)[] = []; export function hide(): void { - $("#words, #koInputVisualContainer") + $("#words, #compositionDisplay") .css("transition", "none") .removeClass("blurred"); $(".outOfFocusWarning").addClass("hidden"); @@ -13,7 +13,7 @@ export function hide(): void { export function show(): void { outOfFocusTimeouts.push( setTimeout(() => { - $("#words, #koInputVisualContainer") + $("#words, #compositionDisplay") .css("transition", "0.25s") .addClass("blurred"); $(".outOfFocusWarning").removeClass("hidden"); diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index b81dc6c75a3d..703c45b89fe1 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -45,6 +45,7 @@ import { canQuickRestart as canQuickRestartFn } from "../utils/quick-restart"; import { LocalStorageWithSchema } from "../utils/local-storage-with-schema"; import { z } from "zod"; import * as TestState from "./test-state"; +import { blurInputElement } from "../input/input-element"; let result: CompletedEvent; let maxChartVal: number; @@ -990,7 +991,7 @@ export async function update( $(".pageTest #result #rateQuoteButton .rating").text(""); $(".pageTest #result #rateQuoteButton").addClass("hidden"); $("#words").removeClass("blurred"); - $("#wordsInput").trigger("blur"); + blurInputElement(); $("#result .stats .time .bottom .afk").text(""); if (isAuthenticated()) { $("#result .loginTip").addClass("hidden"); diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 7878e9e5a1bf..d6a27c714d78 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -1,6 +1,8 @@ import { lastElementFromArray } from "../utils/arrays"; import { mean, roundTo2 } from "@monkeytype/util/numbers"; import * as TestState from "./test-state"; +import Config from "../config"; +import { getInputElementValue } from "../input/input-element"; const keysToTrack = new Set([ "NumpadMultiply", @@ -131,6 +133,10 @@ class Input { return ret; } + get(index: number): string | undefined { + return this.history[index]; + } + getHistory(): string[]; getHistory(i: number): string | undefined; getHistory(i?: number): unknown { @@ -144,6 +150,10 @@ class Input { getHistoryLast(): string | undefined { return lastElementFromArray(this.history); } + + syncWithInputElement(): void { + this.current = getInputElementValue().inputValue; + } } class Corrected { @@ -159,12 +169,33 @@ class Corrected { this.current = ""; } + update(char: string, correct: boolean): void { + if (this.current === "") { + this.current += input.current; + } else { + const currCorrectedTestInputLength = this.current.length; + + const charIndex = input.current.length - 1; + + if (charIndex >= currCorrectedTestInputLength) { + this.current += char; + } else if (!correct) { + this.current = + this.current.substring(0, charIndex) + + char + + this.current.substring(charIndex + 1); + } + } + } + getHistory(i: number): string | undefined { return this.history[i]; } popHistory(): string { - return this.history.pop() ?? ""; + const popped = this.history.pop() ?? ""; + this.current = popped; + return popped; } pushHistory(): void { @@ -296,9 +327,42 @@ export function forceKeyup(now: number): void { } } +function getEventCode(event: KeyboardEvent): string { + if (event.code === "NumpadEnter" && Config.funbox.includes("58008")) { + return "Space"; + } + + if (event.code.includes("Arrow") && Config.funbox.includes("arrows")) { + return "NoCode"; + } + + if ( + event.code === "" || + event.code === undefined || + event.key === "Unidentified" + ) { + return "NoCode"; + } + + return event.code; +} + let noCodeIndex = 0; +export function recordKeyupTime(now: number, event: KeyboardEvent): void { + if (event.repeat) { + console.log( + "Keyup not recorded - repeat", + event.key, + event.code, + //ignore for logging + // eslint-disable-next-line @typescript-eslint/no-deprecated + event.which + ); + return; + } + + let key = getEventCode(event); -export function recordKeyupTime(now: number, key: string): void { if (!keysToTrack.has(key)) return; if (key === "NoCode") { @@ -320,15 +384,24 @@ export function recordKeyupTime(now: number, key: string): void { updateOverlap(now); } -export function recordKeydownTime(now: number, key: string): void { - if (!keysToTrack.has(key)) { - console.debug("Key not tracked", key); +export function recordKeydownTime(now: number, event: KeyboardEvent): void { + if (event.repeat) { + console.log( + "Keydown not recorded - repeat", + event.key, + event.code, + //ignore for logging + // eslint-disable-next-line @typescript-eslint/no-deprecated + event.which + ); return; } - if (key === "NoCode") { - key = "NoCode" + noCodeIndex; - noCodeIndex++; + let key = getEventCode(event); + + if (!keysToTrack.has(key)) { + console.debug("Keydown not recorded - not tracked", key); + return; } if (keyDownData[key] !== undefined) { @@ -336,6 +409,11 @@ export function recordKeydownTime(now: number, key: string): void { return; } + if (key === "NoCode") { + key = "NoCode" + noCodeIndex; + noCodeIndex++; + } + keyDownData[key] = { timestamp: now, index: keypressTimings.duration.array.length, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index e5ce842d5af0..15936d1ee0f9 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -83,9 +83,14 @@ import * as Loader from "../elements/loader"; import * as TestInitFailed from "../elements/test-init-failed"; import { canQuickRestart } from "../utils/quick-restart"; import { animate } from "animejs"; +import * as CompositionDisplay from "../elements/composition-display"; +import { + getInputElement, + isInputElementFocused, + setInputElementValue, +} from "../input/input-element"; let failReason = ""; -const koInputVisual = document.getElementById("koInputVisual") as HTMLElement; export let notSignedInLastResult: CompletedEvent | null = null; @@ -116,13 +121,7 @@ export function startTest(now: number): boolean { Replay.startReplayRecording(); Replay.replayGetWordsList(TestWords.words.list); TestInput.resetKeypressTimings(); - TimerProgress.show(); - LiveSpeed.show(); - LiveAcc.show(); - LiveBurst.show(); - TimerProgress.update(); TestTimer.clear(); - Monkey.show(); for (const fb of getActiveFunboxesWithFunction("start")) { fb.functions.start(); @@ -139,6 +138,7 @@ export function startTest(now: number): boolean { //use a recursive self-adjusting timer to avoid time drift TestStats.setStart(now); void TestTimer.start(); + TestUI.afterTestStart(); return true; } @@ -296,6 +296,7 @@ export function restart(options = {} as RestartOptions): void { QuoteRateModal.clearQuoteStats(); TestUI.reset(); CompositionState.setComposing(false); + CompositionState.setData(""); if (TestState.resultVisible) { if (Config.randomTheme !== "off") { @@ -325,15 +326,14 @@ export function restart(options = {} as RestartOptions): void { onComplete: async () => { $("#result").addClass("hidden"); $("#typingTest").css("opacity", 0).removeClass("hidden"); - $("#wordsInput").css({ left: 0 }).val(" "); + getInputElement().style.left = "0"; + setInputElementValue(""); - if (Config.language.startsWith("korean")) { - koInputVisual.innerText = " "; - Config.mode !== "zen" - ? $("#koInputVisualContainer").show() - : $("#koInputVisualContainer").hide(); + if (CompositionDisplay.shouldShow()) { + CompositionDisplay.update(" "); + CompositionDisplay.show(); } else { - $("#koInputVisualContainer").hide(); + CompositionDisplay.hide(); } Focus.set(false); @@ -378,8 +378,7 @@ export function restart(options = {} as RestartOptions): void { void ModesNotice.update(); } - const isWordsFocused = $("#wordsInput").is(":focus"); - if (isWordsFocused) OutOfFocus.hide(); + if (isInputElementFocused()) OutOfFocus.hide(); TestUI.focusWords(true); const typingTestEl = document.querySelector("#typingTest") as HTMLElement; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 6667e667ad5c..a962323826d3 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -20,11 +20,30 @@ import * as ActivePage from "../states/active-page"; import Format from "../utils/format"; import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs"; import { convertRemToPixels } from "../utils/numbers"; -import { findSingleActiveFunboxWithFunction } from "./funbox/list"; +import { + findSingleActiveFunboxWithFunction, + isFunboxActiveWithProperty, +} from "./funbox/list"; import * as TestState from "./test-state"; import * as PaceCaret from "./pace-caret"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; +import * as SoundController from "../controllers/sound-controller"; +import * as Numbers from "@monkeytype/util/numbers"; +import * as TestStats from "./test-stats"; +import * as KeymapEvent from "../observables/keymap-event"; +import * as LiveAcc from "./live-acc"; +import * as Focus from "../test/focus"; +import * as TimerProgress from "../test/timer-progress"; +import * as LiveBurst from "./live-burst"; +import * as LiveSpeed from "./live-speed"; +import * as Monkey from "./monkey"; import { animate } from "animejs"; +import { + blurInputElement, + focusInputElement, + getInputElement, + isInputElementFocused, +} from "../input/input-element"; const debouncedZipfCheck = debounce(250, async () => { const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); @@ -123,8 +142,9 @@ export let lineTransition = false; export let currentTestLine = 0; export let resultCalculating = false; -export function setActiveWordTop(val: number): void { - activeWordTop = val; +export function setActiveWordTop(): void { + const activeWord = getActiveWordElement(); + activeWordTop = activeWord?.offsetTop ?? 0; } export function setResultCalculating(val: boolean): void { @@ -136,13 +156,10 @@ export function reset(): void { } export function focusWords(force = false): void { - const wordsInput = document.querySelector("#wordsInput"); if (force) { - wordsInput?.blur(); + blurInputElement(); } - wordsInput?.focus({ - preventScroll: true, - }); + focusInputElement(true); if (TestState.isActive) { keepWordsInputInTheCenter(true); } else { @@ -151,14 +168,10 @@ export function focusWords(force = false): void { } } -export function blurWords(): void { - $("#wordsInput").trigger("blur"); -} - export function keepWordsInputInTheCenter(force = false): void { - const wordsInput = document.querySelector("#wordsInput"); + const wordsInput = getInputElement(); const wordsWrapper = document.querySelector("#wordsWrapper"); - if (!wordsInput || !wordsWrapper) return; + if (wordsInput === null || wordsWrapper === null) return; const wordsWrapperHeight = wordsWrapper.offsetHeight; const windowHeight = window.innerHeight; @@ -506,10 +519,10 @@ export function updateWordsInputPosition(): void { ? !TestState.isLanguageRightToLeft : TestState.isLanguageRightToLeft; - const el = document.querySelector("#wordsInput"); + const el = getInputElement(); const wrapperElement = document.querySelector("#wordsWrapper"); - if (!el || !wrapperElement) return; + if (el === null || wrapperElement === null) return; const activeWord = getActiveWordElement(); @@ -718,47 +731,15 @@ export async function updateActiveWordLetters( ret += `${char}`; } } - if (TestInput.input.current === "") { + if (TestInput.input.current === "" && CompositionState.getData() === "") { ret += ``; } - } else { - let correctSoFar = false; - - const containsKorean = TestInput.input.getKoreanStatus(); - if (!containsKorean) { - // slice earlier if input has trailing compose characters - const inputWithoutComposeLength = Misc.trailingComposeChars.test(input) - ? input.search(Misc.trailingComposeChars) - : input.length; - if ( - input.search(Misc.trailingComposeChars) < currentWord.length && - // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with - currentWord.slice(0, inputWithoutComposeLength) === - input.slice(0, inputWithoutComposeLength) - ) { - correctSoFar = true; - } - } else { - // slice earlier if input has trailing compose characters - const koCurrentWord: string = Hangul.disassemble(currentWord).join(""); - const koInput: string = Hangul.disassemble(input).join(""); - const inputWithoutComposeLength: number = Misc.trailingComposeChars.test( - input - ) - ? input.search(Misc.trailingComposeChars) - : koInput.length; - if ( - input.search(Misc.trailingComposeChars) < - Hangul.d(koCurrentWord).length && - // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with - koCurrentWord.slice(0, inputWithoutComposeLength) === - koInput.slice(0, inputWithoutComposeLength) - ) { - correctSoFar = true; - } + const compositionData = CompositionState.getData(); + for (const char of compositionData) { + ret += `${char}`; } - + } else { const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); const inputChars = Strings.splitIntoCharacters(input); @@ -784,23 +765,14 @@ export async function updateActiveWordLetters( if (charCorrect) { ret += `${currentLetter}`; - } else if ( - currentLetter !== undefined && - CompositionState.getComposing() && - i >= CompositionState.getStartPos() && - !(containsKorean && !correctSoFar) - ) { - ret += `${ - Config.indicateTypos === "replace" || Config.indicateTypos === "both" - ? inputChars[i] === " " - ? "_" - : inputChars[i] - : currentLetter - }`; } else if (currentLetter === undefined) { let letter = inputChars[i]; - if (letter === " " || letter === "\t" || letter === "\n") { + if (letter === " ") { letter = "_"; + } else if (letter === "\t") { + letter = ""; + } else if (letter === "\n") { + letter = ""; } ret += `${letter}`; } else { @@ -824,7 +796,27 @@ export async function updateActiveWordLetters( } } - for (let i = inputChars.length; i < currentWordChars.length; i++) { + const compositionData = CompositionState.getData(); + for (let i = 0; i < compositionData.length; i++) { + const compositionChar = compositionData[i]; + let charToShow = currentWordChars[input.length + i]; + + if (charToShow === undefined) { + charToShow = compositionChar; + } + + if (Config.indicateTypos === "replace") { + charToShow = compositionChar === " " ? "_" : compositionChar; + } + + ret += `${charToShow}`; + } + + for ( + let i = inputChars.length + compositionData.length; + i < currentWordChars.length; + i++ + ) { const currentLetter = currentWordChars[i]; if (funbox?.functions?.getWordHtml) { ret += funbox.functions.getWordHtml(currentLetter as string, true); @@ -1527,13 +1519,14 @@ export async function applyBurstHeatmap(): Promise { } export function highlightBadWord(index: number): void { - $(getWordElement(index) as HTMLElement).addClass("error"); + getWordElement(index)?.classList.add("error"); } export function highlightAllLettersAsCorrect(wordIndex: number): void { - $(getWordElement(wordIndex) as HTMLElement) - .find("letter") - .addClass("correct"); + const letters = getWordElement(wordIndex)?.children; + for (const letter of letters ?? []) { + letter.classList.add("correct"); + } } function updateWordsWidth(): void { @@ -1624,6 +1617,171 @@ function updateLiveStatsColor(value: TimerColor): void { } } +export function getActiveWordTopAfterAppend(data: string): number { + const activeWord = getActiveWordElement(); + + if (!activeWord) throw new Error("No active word element found"); + + const displayData = data === " " ? "_" : data; + + const tempLetter = document.createElement("letter"); + tempLetter.className = "temp"; + tempLetter.textContent = displayData; + + activeWord.appendChild(tempLetter); + + const top = activeWord.offsetTop; + tempLetter.remove(); + + return top; +} + +// this means input, delete or composition +function afterAnyTestInput(correctInput: boolean | null): void { + if ( + correctInput === true || + Config.playSoundOnError === "off" || + Config.blindMode + ) { + void SoundController.playClick(); + } else { + void SoundController.playError(); + } + + const acc: number = Numbers.roundTo2(TestStats.calculateAccuracy()); + if (!isNaN(acc)) LiveAcc.update(acc); + + if (Config.mode !== "time") { + TimerProgress.update(); + } + + if (Config.keymapMode === "next") { + void KeymapEvent.highlight( + TestWords.words.getCurrent().charAt(TestInput.input.current.length) + ); + } + + Focus.set(true); + Caret.stopAnimation(); + Caret.updatePosition(); +} + +export function afterTestTextInput( + correct: boolean, + increasedWordIndex: boolean | null, + inputOverride?: string +): void { + //nospace cant be handled here becauseword index + // is already increased at this point + + setActiveWordTop(); + if (!increasedWordIndex) void updateActiveWordLetters(inputOverride); + + if (Config.mode === "zen") { + const currentTop = getActiveWordElement()?.offsetTop; + if (currentTop !== undefined && currentTop > activeWordTop) { + void lineJump(activeWordTop, true); + } + } + + afterAnyTestInput(correct); +} + +export function afterTestCompositionUpdate(): void { + void updateActiveWordLetters(); + // correct needs to be true to get the normal click sound + afterAnyTestInput(true); +} + +export function afterTestDelete(): void { + void updateActiveWordLetters(); + afterAnyTestInput(null); +} + +export function beforeTestWordChange( + direction: "forward", + correct: boolean, + forceUpdateActiveWordLetters: boolean +): void; +export function beforeTestWordChange( + direction: "back", + correct: null, + forceUpdateActiveWordLetters: boolean +): void; +export function beforeTestWordChange( + direction: "forward" | "back", + correct: boolean | null, + forceUpdateActiveWordLetters: boolean +): void { + const nospaceEnabled = isFunboxActiveWithProperty("nospace"); + if ( + (Config.stopOnError === "letter" && (correct || correct === null)) || + nospaceEnabled || + forceUpdateActiveWordLetters + ) { + void updateActiveWordLetters(); + } + + if (direction === "forward") { + if (Config.blindMode) { + highlightAllLettersAsCorrect(TestState.activeWordIndex); + } else if (correct === false) { + highlightBadWord(TestState.activeWordIndex); + } + } +} + +export async function afterTestWordChange( + direction: "forward" | "back" +): Promise { + updateActiveElement(); + Caret.updatePosition(); + + const lastBurst = TestInput.burstHistory[TestInput.burstHistory.length - 1]; + if (Numbers.isSafeNumber(lastBurst)) { + void LiveBurst.update(Math.round(lastBurst)); + } + if (direction === "forward") { + if ( + !Config.showAllLines || + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimitValue() === 0) || + (Config.mode === "custom" && CustomText.getLimitMode() === "time") + ) { + const previousWord = getWordElement(TestState.activeWordIndex - 1); + const activeWord = getActiveWordElement(); + + if (!previousWord || !activeWord) return; + + const previousTop = previousWord.offsetTop; + const activeTop = activeWord.offsetTop; + + if ( + activeTop !== null && + previousTop !== null && + Math.floor(activeTop) > Math.floor(previousTop) + ) { + void lineJump(previousTop); + } + } + } else if (direction === "back") { + if (Config.mode === "zen") { + getWordElement(TestState.activeWordIndex + 1)?.remove(); + } + } +} + +export function afterTestStart(): void { + setActiveWordTop(); + Focus.set(true); + Monkey.show(); + TimerProgress.show(); + LiveSpeed.show(); + LiveAcc.show(); + LiveBurst.show(); + TimerProgress.update(); +} + $(".pageTest #copyWordsListButton").on("click", async () => { let words; if (Config.mode === "zen") { @@ -1706,8 +1864,7 @@ addEventListener("resize", () => { }); $("#wordsInput").on("focus", (e) => { - const wordsFocused = e.target === document.activeElement; - if (!wordsFocused) return; + if (!isInputElementFocused()) return; if (!TestState.resultVisible && Config.showOutOfFocusWarning) { OutOfFocus.hide(); } diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index cbe08ce29d9f..9ea94caee40c 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -261,25 +261,30 @@ export function isWordRightToLeft( return reverseDirection ? !result : result; } -export const CHAR_EQUIVALENCE_MAPS = [ - new Map( - ["’", "‘", "'", "ʼ", "׳", "ʻ", "᾽", "᾽"].map((char, index) => [char, index]) - ), - new Map([`"`, "”", "“", "„"].map((char, index) => [char, index])), - new Map(["–", "—", "-", "‐"].map((char, index) => [char, index])), - new Map([",", "‚"].map((char, index) => [char, index])), +export const CHAR_EQUIVALENCE_SETS = [ + new Set(["’", "‘", "'", "ʼ", "׳", "ʻ", "᾽", "᾽"]), + new Set([`"`, "”", "“", "„"]), + new Set(["–", "—", "-", "‐"]), + new Set([",", "‚"]), ]; +export const LANGUAGE_EQUIVALENCE_SETS: Partial>> = + { + russian: new Set(["ё", "е", "e"]), + }; + /** * Checks if two characters are visually/typographically equivalent for typing purposes. * This allows users to type different variants of the same character and still be considered correct. * @param char1 The first character to compare * @param char2 The second character to compare + * @param language Optional language context to check for language-specific equivalences * @returns true if the characters are equivalent, false otherwise */ export function areCharactersVisuallyEqual( char1: string, - char2: string + char2: string, + language?: Language ): boolean { // If characters are exactly the same, they're equivalent if (char1 === char2) { @@ -287,12 +292,21 @@ export function areCharactersVisuallyEqual( } // Check each equivalence map - for (const map of CHAR_EQUIVALENCE_MAPS) { + for (const map of CHAR_EQUIVALENCE_SETS) { if (map.has(char1) && map.has(char2)) { return true; } } + if (language !== undefined) { + const langMap = LANGUAGE_EQUIVALENCE_SETS[removeLanguageSize(language)]; + if (langMap !== undefined) { + if (langMap.has(char1) && langMap.has(char2)) { + return true; + } + } + } + return false; } @@ -310,6 +324,33 @@ export function toHex(buffer: ArrayBuffer): string { return hashHex; } +/** + * Checks if a character is a directly typable space character on a standard keyboard. + * These are space characters that can be typed without special input methods or copy-pasting. + * @param char The character to check. + * @returns True if the character is a directly typable space, false otherwise. + */ +export function isSpace(char: string): boolean { + if (char.length !== 1) return false; + + const codePoint = char.codePointAt(0); + if (codePoint === undefined) return false; + + // Directly typable spaces: + // U+0020 - Regular space (spacebar) + // U+2002 - En space (Option+Space on Mac) + // U+2003 - Em space (Option+Shift+Space on Mac) + // U+2009 - Thin space (various input methods) + // U+3000 - Ideographic space (CJK input methods) + return ( + codePoint === 0x0020 || + codePoint === 0x2002 || + codePoint === 0x2003 || + codePoint === 0x2009 || + codePoint === 0x3000 + ); +} + // Export testing utilities for unit tests export const __testing = { hasRTLCharacters, diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index 2f49d3728cf4..84aab9025a9f 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -14,7 +14,7 @@ const list: Record = { "getWord", "punctuateWord", "rememberSettings", - "handleChar", + "getEmulatedChar", ], name: "58008", alias: "numbers", @@ -107,9 +107,8 @@ const list: Record = { frontendFunctions: [ "getWord", "rememberSettings", - "handleChar", + "getEmulatedChar", "isCharCorrect", - "preventDefaultEvent", "getWordHtml", ], name: "arrows", diff --git a/packages/funbox/src/validation.ts b/packages/funbox/src/validation.ts index f866fd959b47..d20e25aefc40 100644 --- a/packages/funbox/src/validation.ts +++ b/packages/funbox/src/validation.ts @@ -109,6 +109,10 @@ export function checkCompatibility( funboxesToCheck.filter((f) => f.frontendFunctions?.includes("punctuateWord") ).length <= 1; + const oneGetEmulatedCharMax = + funboxesToCheck.filter((f) => + f.frontendFunctions?.includes("getEmulatedChar") + ).length <= 1; const oneCharCheckerMax = funboxesToCheck.filter((f) => f.frontendFunctions?.includes("isCharCorrect") @@ -170,6 +174,7 @@ export function checkCompatibility( hasLanguageToSpeakAndNoUnspeakable && oneToPushOrPullSectionMax && onePunctuateWordMax && + oneGetEmulatedCharMax && oneCharCheckerMax && oneCharReplacerMax && oneChangesCapitalisationMax &&