From e51550683aad755cbc0b08c81416fd262046d173 Mon Sep 17 00:00:00 2001 From: Jack Date: Sat, 13 Dec 2025 23:55:58 +0100 Subject: [PATCH 1/3] refactor: clean up test-ui and test-logic (@miodec) (#7229) Move ui code to test ui, remove unused code, remove duplicated code, merge the two config event listeners, merge a lot of the ui code to make it easier to grasp. --- .../test/funbox/layoutfluid-funbox-timer.ts | 4 + frontend/src/ts/test/live-acc.ts | 15 +- frontend/src/ts/test/live-burst.ts | 15 +- frontend/src/ts/test/live-speed.ts | 15 +- frontend/src/ts/test/monkey.ts | 7 + frontend/src/ts/test/test-logic.ts | 140 +++------ frontend/src/ts/test/test-ui.ts | 288 +++++++++--------- frontend/src/ts/test/timer-progress.ts | 10 + 8 files changed, 251 insertions(+), 243 deletions(-) diff --git a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts index 3dfe9c3197f1..f80d95a025a1 100644 --- a/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts +++ b/frontend/src/ts/test/funbox/layoutfluid-funbox-timer.ts @@ -20,6 +20,10 @@ export function hide(): void { }); } +export function instantHide(): void { + timerEl.style.opacity = "0"; +} + export function updateTime(sec: number, layout: string): void { timerEl.textContent = `${capitalizeFirstLetter(layout)} in: ${sec}s`; } diff --git a/frontend/src/ts/test/live-acc.ts b/frontend/src/ts/test/live-acc.ts index a2b3925aa349..3158d0e36928 100644 --- a/frontend/src/ts/test/live-acc.ts +++ b/frontend/src/ts/test/live-acc.ts @@ -7,8 +7,8 @@ import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-fra const textEl = document.querySelector( "#liveStatsTextBottom .liveAcc", -) as Element; -const miniEl = document.querySelector("#liveStatsMini .acc") as Element; +) as HTMLElement; +const miniEl = document.querySelector("#liveStatsMini .acc") as HTMLElement; export function update(acc: number): void { requestDebouncedAnimationFrame("live-acc.update", () => { @@ -73,6 +73,17 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveAccStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/live-burst.ts b/frontend/src/ts/test/live-burst.ts index 93e37f5c9096..b37537d5de30 100644 --- a/frontend/src/ts/test/live-burst.ts +++ b/frontend/src/ts/test/live-burst.ts @@ -8,8 +8,8 @@ import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-fra const textEl = document.querySelector( "#liveStatsTextBottom .liveBurst", -) as Element; -const miniEl = document.querySelector("#liveStatsMini .burst") as Element; +) as HTMLElement; +const miniEl = document.querySelector("#liveStatsMini .burst") as HTMLElement; export function reset(): void { requestDebouncedAnimationFrame("live-burst.reset", () => { @@ -71,6 +71,17 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveBurstStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/live-speed.ts b/frontend/src/ts/test/live-speed.ts index 319b8daf288e..cef8273e4c04 100644 --- a/frontend/src/ts/test/live-speed.ts +++ b/frontend/src/ts/test/live-speed.ts @@ -8,8 +8,10 @@ import { animate } from "animejs"; const textElement = document.querySelector( "#liveStatsTextBottom .liveSpeed", -) as Element; -const miniElement = document.querySelector("#liveStatsMini .speed") as Element; +) as HTMLElement; +const miniElement = document.querySelector( + "#liveStatsMini .speed", +) as HTMLElement; export function reset(): void { requestDebouncedAnimationFrame("live-speed.reset", () => { @@ -75,6 +77,15 @@ export function hide(): void { }); } +export function instantHide(): void { + if (!state) return; + miniElement.classList.add("hidden"); + miniElement.style.opacity = "0"; + textElement.classList.add("hidden"); + textElement.style.opacity = "0"; + state = false; +} + ConfigEvent.subscribe(({ key, newValue }) => { if (key === "liveSpeedStyle") newValue === "off" ? hide() : show(); }); diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index 49edd43007f1..b1966c3e4c86 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -162,3 +162,10 @@ export function hide(): void { }, }); } + +export function instantHide(): void { + monkeyEl.classList.add("hidden"); + monkeyEl.style.opacity = "0"; + monkeyEl.style.animationDuration = "0s"; + monkeyFastEl.style.opacity = "0"; +} diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8a680b8a0f43..689cc34db4d3 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -12,23 +12,12 @@ import * as CustomText from "./custom-text"; import * as CustomTextState from "../states/custom-text-name"; import * as TestStats from "./test-stats"; import * as PractiseWords from "./practise-words"; -import * as SoundController from "../controllers/sound-controller"; import * as ShiftTracker from "./shift-tracker"; import * as AltTracker from "./alt-tracker"; -import * as Focus from "./focus"; import * as Funbox from "./funbox/funbox"; -import * as Keymap from "../elements/keymap"; -import * as ThemeController from "../controllers/theme-controller"; -import * as ResultWordHighlight from "../elements/result-word-highlight"; import * as PaceCaret from "./pace-caret"; import * as Caret from "./caret"; -import * as LiveSpeed from "./live-speed"; -import * as LiveAcc from "./live-acc"; -import * as LiveBurst from "./live-burst"; -import * as TimerProgress from "./timer-progress"; - import * as TestTimer from "./test-timer"; -import * as OutOfFocus from "./out-of-focus"; import * as AccountButton from "../elements/account-button"; import * as DB from "../db"; import * as Replay from "./replay"; @@ -36,27 +25,20 @@ import * as TodayTracker from "./today-tracker"; import * as ChallengeContoller from "../controllers/challenge-controller"; import * as QuoteRateModal from "../modals/quote-rate"; import * as Result from "./result"; -import * as MonkeyPower from "../elements/monkey-power"; + import * as ActivePage from "../states/active-page"; import * as TestInput from "./test-input"; import * as TestWords from "./test-words"; import * as WordsGenerator from "./words-generator"; import * as TestState from "./test-state"; -import * as ModesNotice from "../elements/modes-notice"; import * as PageTransition from "../states/page-transition"; import * as ConfigEvent from "../observables/config-event"; import * as TimerEvent from "../observables/timer-event"; -import * as Last10Average from "../elements/last-10-average"; -import * as Monkey from "./monkey"; import objectHash from "object-hash"; import * as AnalyticsController from "../controllers/analytics-controller"; import { getAuthenticatedUser, isAuthenticated } from "../firebase"; -import * as AdController from "../controllers/ad-controller"; -import * as TestConfig from "./test-config"; import * as ConnectionState from "../states/connection"; -import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; import * as KeymapEvent from "../observables/keymap-event"; -import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; import * as ArabicLazyMode from "../states/arabic-lazy-mode"; import Format from "../utils/format"; import { QuoteLength, QuoteLengthConfig } from "@monkeytype/schemas/configs"; @@ -84,11 +66,8 @@ 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 { - getInputElement, - isInputElementFocused, - setInputElementValue, -} from "../input/input-element"; +import { setInputElementValue } from "../input/input-element"; +import { debounce } from "throttle-debounce"; let failReason = ""; @@ -138,7 +117,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(); + TestUI.onTestStart(); return true; } @@ -281,30 +260,14 @@ export function restart(options = {} as RestartOptions): void { Caret.hide(); TestState.setActive(false); Replay.stopReplayRecording(); - LiveSpeed.hide(); - LiveAcc.hide(); - LiveBurst.hide(); - TimerProgress.hide(); Replay.pauseReplay(); TestState.setBailedOut(false); Caret.resetPosition(); PaceCaret.reset(); - Monkey.hide(); TestInput.input.setKoreanStatus(false); - LayoutfluidFunboxTimer.hide(); - MemoryFunboxTimer.reset(); QuoteRateModal.clearQuoteStats(); - TestUI.reset(); CompositionState.setComposing(false); CompositionState.setData(""); - void SoundController.clearAllSounds(); - - if (TestState.resultVisible) { - if (Config.randomTheme !== "off") { - void ThemeController.randomizeTheme(); - } - void XPBar.skipBreakdown(); - } if (!ConnectionState.get()) { ConnectionState.showOfflineBanner(); @@ -318,25 +281,14 @@ export function restart(options = {} as RestartOptions): void { //words are being displayed el = document.querySelector("#typingTest") as HTMLElement; } - TestState.setResultVisible(false); TestState.setTestRestarting(true); animate(el, { opacity: 0, duration: animationTime, onComplete: async () => { - $("#result").addClass("hidden"); - $("#typingTest").css("opacity", 0).removeClass("hidden"); - getInputElement().style.left = "0"; setInputElementValue(""); - Focus.set(false); - if (ActivePage.get() === "test") { - AdController.updateFooterAndVerticalAds(false); - } - TestConfig.show(); - AdController.destroyResult(); - await Funbox.rememberSettings(); testReinitCount = 0; @@ -364,18 +316,8 @@ export function restart(options = {} as RestartOptions): void { fb.functions.restart(); } - if (Config.showAverage !== "off") { - void Last10Average.update().then(() => { - void ModesNotice.update(); - }); - } else { - void ModesNotice.update(); - } - - if (isInputElementFocused()) OutOfFocus.hide(); - TestUI.focusWords(true); - TestUI.onTestRestart(); + TestState.setResultVisible(false); const typingTestEl = document.querySelector("#typingTest") as HTMLElement; animate(typingTestEl, { @@ -385,19 +327,12 @@ export function restart(options = {} as RestartOptions): void { }, duration: animationTime, onComplete: () => { - TimerProgress.reset(); - LiveSpeed.reset(); - LiveAcc.reset(); - LiveBurst.reset(); - TestUI.updatePremid(); ManualRestart.reset(); TestState.setTestRestarting(false); }, }); }, }); - - ResultWordHighlight.destroy(); } let lastInitError: Error | null = null; @@ -418,18 +353,9 @@ async function init(): Promise { TestInitFailed.show(); TestState.setTestRestarting(false); TestState.setTestInitSuccess(false); - Focus.set(false); - // Notifications.add( - // "Too many test reinitialization attempts. Something is going very wrong. Please contact support.", - // -1, - // { - // important: true, - // } - // ); return false; } - MonkeyPower.reset(); Replay.stopReplayRecording(); TestWords.words.reset(); TestState.setActiveWordIndex(0); @@ -581,8 +507,6 @@ async function init(): Promise { return await init(); } - const beforeHasNumbers = TestWords.hasNumbers; - let hasNumbers = false; for (const word of generatedWords) { @@ -595,10 +519,6 @@ async function init(): Promise { TestWords.setHasTab(wordsHaveTab); TestWords.setHasNewline(wordsHaveNewline); - if (beforeHasNumbers !== hasNumbers) { - void Keymap.refresh(); - } - if ( generatedWords .join() @@ -631,13 +551,11 @@ async function init(): Promise { TestUI.setLigatures(allLigatures ?? language.ligatures ?? false); const isLanguageRTL = allRightToLeft ?? language.rightToLeft ?? false; - TestUI.setRightToLeft(isLanguageRTL); TestState.setIsLanguageRightToLeft(isLanguageRTL); TestState.setIsDirectionReversed( isFunboxActiveWithProperty("reverseDirection"), ); - TestUI.showWords(); console.debug("Test initialized with words", generatedWords); console.debug( "Test initialized with section indexes", @@ -781,8 +699,6 @@ export async function retrySavingResult(): Promise { retrySaving.canRetry = false; $("#retrySavingResultButton").addClass("hidden"); - AccountButton.loading(true); - Notifications.add("Retrying to save..."); await saveResult(completedEvent, true); @@ -944,6 +860,8 @@ export async function finish(difficultyFailed = false): Promise { $(".pageTest .loading").removeClass("hidden"); await Misc.sleep(0); //allow ui update + TestUI.onTestFinish(); + if (TestState.isRepeated && Config.mode === "quote") { TestState.setRepeated(false); } @@ -972,14 +890,6 @@ export async function finish(difficultyFailed = false): Promise { TestState.setResultVisible(true); TestState.setActive(false); Replay.stopReplayRecording(); - Caret.hide(); - LiveSpeed.hide(); - LiveAcc.hide(); - LiveBurst.hide(); - TimerProgress.hide(); - OutOfFocus.hide(); - Monkey.hide(); - void ModesNotice.update(); //need one more calculation for the last word if test auto ended if (TestInput.burstHistory.length !== TestInput.input.getHistory()?.length) { @@ -1290,8 +1200,6 @@ export async function finish(difficultyFailed = false): Promise { Result.updateRateQuote(TestWords.currentQuote); - AccountButton.loading(true); - if (!completedEvent.bailedOut) { const challenge = ChallengeContoller.verify(completedEvent); if (challenge !== null) completedEvent.challenge = challenge; @@ -1306,6 +1214,8 @@ async function saveResult( completedEvent: CompletedEvent, isRetrying: boolean, ): Promise { + AccountButton.loading(true); + if (!TestState.savingEnabled) { Notifications.add("Result not saved: disabled by user", -1, { duration: 3, @@ -1468,6 +1378,32 @@ export function fail(reason: string): void { TestStats.pushIncompleteTest(acc, tt); } +const debouncedZipfCheck = debounce(250, async () => { + const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); + if (supports === "no") { + Notifications.add( + `${Strings.capitalizeFirstLetter( + Strings.getLanguageDisplayString(Config.language), + )} does not support Zipf funbox, because the list is not ordered by frequency. Please try another word list.`, + 0, + { + duration: 7, + }, + ); + } + if (supports === "unknown") { + Notifications.add( + `${Strings.capitalizeFirstLetter( + Strings.getLanguageDisplayString(Config.language), + )} may not support Zipf funbox, because we don't know if it's ordered by frequency or not. If you would like to add this label, please contact us.`, + 0, + { + duration: 7, + }, + ); + } +}); + $(".pageTest").on("click", "#testModesNotice .textButton.restart", () => { restart(); }); @@ -1622,6 +1558,12 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => { ); }, 0); } + if ( + (key === "language" || key === "funbox") && + Config.funbox.includes("zipf") + ) { + debouncedZipfCheck(); + } } if (key === "lazyMode" && !nosave) { if (Config.language.startsWith("arabic")) { diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 4ec86b2f9cdf..6ebe02254b00 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -8,13 +8,11 @@ import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; -import * as JSONData from "../utils/json-data"; import { blendTwoHexColors } from "../utils/colors"; import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; import * as CompositionState from "../states/composition"; import * as ConfigEvent from "../observables/config-event"; import * as Hangul from "hangul-js"; -import { debounce } from "throttle-debounce"; import * as ResultWordHighlight from "../elements/result-word-highlight"; import * as ActivePage from "../states/active-page"; import Format from "../utils/format"; @@ -49,105 +47,22 @@ import { } from "../input/input-element"; import * as MonkeyPower from "../elements/monkey-power"; import * as SlowTimer from "../states/slow-timer"; +import * as TestConfig from "./test-config"; import * as CompositionDisplay from "../elements/composition-display"; - -const debouncedZipfCheck = debounce(250, async () => { - const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); - if (supports === "no") { - Notifications.add( - `${Strings.capitalizeFirstLetter( - Strings.getLanguageDisplayString(Config.language), - )} does not support Zipf funbox, because the list is not ordered by frequency. Please try another word list.`, - 0, - { - duration: 7, - }, - ); - } - if (supports === "unknown") { - Notifications.add( - `${Strings.capitalizeFirstLetter( - Strings.getLanguageDisplayString(Config.language), - )} may not support Zipf funbox, because we don't know if it's ordered by frequency or not. If you would like to add this label, please contact us.`, - 0, - { - duration: 7, - }, - ); - } -}); +import * as AdController from "../controllers/ad-controller"; +import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; +import * as Keymap from "../elements/keymap"; +import * as ThemeController from "../controllers/theme-controller"; +import * as XPBar from "../elements/xp-bar"; +import * as ModesNotice from "../elements/modes-notice"; +import * as Last10Average from "../elements/last-10-average"; +import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; export const updateHintsPositionDebounced = Misc.debounceUntilResolved( updateHintsPosition, { rejectSkippedCalls: false }, ); -ConfigEvent.subscribe(({ key, newValue, nosave }) => { - if ( - (key === "language" || key === "funbox") && - Config.funbox.includes("zipf") - ) { - debouncedZipfCheck(); - } - if (key === "fontSize") { - $( - "#caret, #paceCaret, #liveStatsMini, #typingTest, #wordsInput, #compositionDisplay", - ).css("fontSize", newValue + "rem"); - if (!nosave) { - OutOfFocus.hide(); - updateWordWrapperClasses(); - } - } - if ( - ["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes( - key ?? "", - ) - ) { - void updateHintsPositionDebounced(); - } - - if (key === "theme") void applyBurstHeatmap(); - - if (newValue === undefined) return; - if (key === "highlightMode") { - if (ActivePage.get() === "test") { - void updateWordLetters({ - input: TestInput.input.current, - wordIndex: TestState.activeWordIndex, - compositionData: CompositionState.getData(), - }); - } - } - - if ( - [ - "highlightMode", - "blindMode", - "indicateTypos", - "tapeMode", - "hideExtraLetters", - ].includes(key) - ) { - updateWordWrapperClasses(); - } - - if (["tapeMode", "tapeMargin"].includes(key)) { - updateLiveStatsMargin(); - } - - if (key === "showAllLines") { - updateWordsWrapperHeight(true); - if (!newValue) { - void centerActiveLine(); - } - } - - if (typeof newValue !== "boolean") return; - if (key === "flipTestColors") flipColors(newValue); - if (key === "colorfulMode") colorful(newValue); - if (key === "burstHeatmap") void applyBurstHeatmap(); -}); - const wordsEl = document.querySelector(".pageTest #words") as HTMLElement; const wordsWrapperEl = document.querySelector( ".pageTest #wordsWrapper", @@ -163,11 +78,6 @@ export function setResultCalculating(val: boolean): void { resultCalculating = val; } -export function reset(): void { - currentTestLine = 0; - cancelPendingAnimationFramesStartingWith("test-ui"); -} - export function focusWords(force = false): void { if (force) { blurInputElement(); @@ -475,6 +385,9 @@ function buildWordHTML(word: string, wordIndex: number): string { } function updateWordWrapperClasses(): void { + // outoffocus applies transition, need to remove it + OutOfFocus.hide(); + if (Config.tapeMode !== "off") { wordsEl.classList.add("tape"); wordsWrapperEl.classList.add("tape"); @@ -507,6 +420,32 @@ function updateWordWrapperClasses(): void { wordsWrapperEl.classList.remove("hideExtraLetters"); } + if (Config.flipTestColors) { + wordsEl.classList.add("flipped"); + } else { + wordsEl.classList.remove("flipped"); + } + + if (Config.colorfulMode) { + wordsEl.classList.add("colorfulMode"); + } else { + wordsEl.classList.remove("colorfulMode"); + } + + $( + "#caret, #paceCaret, #liveStatsMini, #typingTest, #wordsInput, #compositionDisplay", + ).css("fontSize", Config.fontSize + "rem"); + + if (TestState.isLanguageRightToLeft) { + wordsEl.classList.add("rightToLeftTest"); + $("#resultWordsHistory .words").addClass("rightToLeftTest"); + $("#resultReplay .words").addClass("rightToLeftTest"); + } else { + wordsEl.classList.remove("rightToLeftTest"); + $("#resultWordsHistory .words").removeClass("rightToLeftTest"); + $("#resultReplay .words").removeClass("rightToLeftTest"); + } + const existing = wordsEl?.className .split(/\s+/) @@ -514,18 +453,24 @@ function updateWordWrapperClasses(): void { if (Config.highlightMode !== null) { existing.push("highlight-" + Config.highlightMode.replaceAll("_", "-")); } - wordsEl.className = existing.join(" "); updateWordsWidth(); updateWordsWrapperHeight(true); + if (!Config.showAllLines) { + void centerActiveLine(); + } updateWordsMargin(); updateWordsInputPosition(); void updateHintsPositionDebounced(); Caret.updatePosition(); + + if (document.activeElement !== getInputElement()) { + OutOfFocus.show(); + } } -export function showWords(): void { +function showWords(): void { wordsEl.innerHTML = ""; if (Config.mode === "zen") { @@ -751,22 +696,6 @@ export function addWord( // }); } -export function flipColors(tf: boolean): void { - if (tf) { - wordsEl.classList.add("flipped"); - } else { - wordsEl.classList.remove("flipped"); - } -} - -export function colorful(tc: boolean): void { - if (tc) { - wordsEl.classList.add("colorfulMode"); - } else { - wordsEl.classList.remove("colorfulMode"); - } -} - // because of the requestAnimationFrame, multiple calls to updateWordLetters // can be made before the actual update happens. This map keeps track of the // latest input for each word and is used in before-insert-text to @@ -1292,18 +1221,6 @@ export async function lineJump( return; } -export function setRightToLeft(isEnabled: boolean): void { - if (isEnabled) { - wordsEl.classList.add("rightToLeftTest"); - $("#resultWordsHistory .words").addClass("rightToLeftTest"); - $("#resultReplay .words").addClass("rightToLeftTest"); - } else { - wordsEl.classList.remove("rightToLeftTest"); - $("#resultWordsHistory .words").removeClass("rightToLeftTest"); - $("#resultReplay .words").removeClass("rightToLeftTest"); - } -} - export function setLigatures(isEnabled: boolean): void { if (isEnabled || Config.mode === "custom" || Config.mode === "zen") { wordsEl.classList.add("withLigatures"); @@ -1732,6 +1649,14 @@ function updateLiveStatsColor(value: TimerColor): void { } } +function showHideTestRestartButton(showHide: boolean): void { + if (showHide) { + $(".pageTest #restartTestButton").removeClass("hidden"); + } else { + $(".pageTest #restartTestButton").addClass("hidden"); + } +} + export function getActiveWordTopAndHeightWithDifferentData(data: string): { top: number; height: number; @@ -1909,7 +1834,7 @@ export async function afterTestWordChange( } } -export function afterTestStart(): void { +export function onTestStart(): void { Focus.set(true); Monkey.show(); TimerProgress.show(); @@ -1920,12 +1845,67 @@ export function afterTestStart(): void { } export function onTestRestart(): void { + $("#result").addClass("hidden"); + $("#typingTest").css("opacity", 0).removeClass("hidden"); + getInputElement().style.left = "0"; + TestConfig.show(); + Focus.set(false); + LiveSpeed.instantHide(); + LiveSpeed.reset(); + LiveBurst.instantHide(); + LiveBurst.reset(); + LiveAcc.instantHide(); + LiveAcc.reset(); + TimerProgress.instantHide(); + TimerProgress.reset(); + Monkey.instantHide(); + LayoutfluidFunboxTimer.instantHide(); + updatePremid(); + focusWords(true); + void Keymap.refresh(); + ResultWordHighlight.destroy(); + MonkeyPower.reset(); + MemoryFunboxTimer.reset(); + + if (Config.showAverage !== "off") { + void Last10Average.update().then(() => { + void ModesNotice.update(); + }); + } else { + void ModesNotice.update(); + } + + if (TestState.resultVisible) { + if (Config.randomTheme !== "off") { + void ThemeController.randomizeTheme(); + } + void XPBar.skipBreakdown(); + } + + currentTestLine = 0; + if (ActivePage.get() === "test") { + AdController.updateFooterAndVerticalAds(false); + } + AdController.destroyResult(); if (Config.compositionDisplay === "below") { CompositionDisplay.update(" "); CompositionDisplay.show(); } else { CompositionDisplay.hide(); } + void SoundController.clearAllSounds(); + cancelPendingAnimationFramesStartingWith("test-ui"); + showWords(); +} + +export function onTestFinish(): void { + Caret.hide(); + LiveSpeed.hide(); + LiveAcc.hide(); + LiveBurst.hide(); + TimerProgress.hide(); + OutOfFocus.hide(); + Monkey.hide(); } $(".pageTest #copyWordsListButton").on("click", async () => { @@ -2034,14 +2014,7 @@ $("#wordsWrapper").on("click", () => { ConfigEvent.subscribe(({ key, newValue }) => { if (key === "quickRestart") { - if (newValue === "off") { - $(".pageTest #restartTestButton").removeClass("hidden"); - } else { - $(".pageTest #restartTestButton").addClass("hidden"); - } - } - if (key === "maxLineWidth") { - updateWordsWidth(); + showHideTestRestartButton(newValue === "off"); } if (key === "timerOpacity") { updateLiveStatsOpacity(newValue); @@ -2060,4 +2033,43 @@ ConfigEvent.subscribe(({ key, newValue }) => { CompositionDisplay.hide(); } } + if ( + ["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes( + key ?? "", + ) + ) { + void updateHintsPositionDebounced(); + } + if ((key === "theme" || key === "burstHeatmap") && TestState.resultVisible) { + void applyBurstHeatmap(); + } + if (key === "highlightMode") { + if (ActivePage.get() === "test") { + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); + } + } + if ( + [ + "highlightMode", + "blindMode", + "indicateTypos", + "tapeMode", + "hideExtraLetters", + "flipTestColors", + "colorfulMode", + "showAllLines", + "fontSize", + "maxLineWidth", + "tapeMargin", + ].includes(key) + ) { + updateWordWrapperClasses(); + } + if (["tapeMode", "tapeMargin"].includes(key)) { + updateLiveStatsMargin(); + } }); diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ddc931e67cc1..a1fe882caa08 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -110,6 +110,16 @@ export function hide(): void { }); } +export function instantHide(): void { + barOpacityEl.style.opacity = "0"; + + miniEl.classList.add("hidden"); + miniEl.style.opacity = "0"; + + textEl.classList.add("hidden"); + textEl.style.opacity = "0"; +} + function getCurrentCount(): number { if (Config.mode === "custom" && CustomText.getLimitMode() === "section") { return ( From b1aa14c6b75dea6df924d9f6a8230b896fa3e793 Mon Sep 17 00:00:00 2001 From: Seif Soliman Date: Sun, 14 Dec 2025 01:10:19 +0200 Subject: [PATCH 2/3] fix(pace-caret): prevent null dereference in update() (@byseif21) (#7226) ### Description * `update()` could hit a race where settings became `null` between checks, causing a`TypeError` at runtime. * Async callbacks (setTimeout) accessed the global settings after it was cleared, leading to runaway errors. Screenshot 2025-12-12 135316 **fix** settings once (currentSettings) at the start of update() and use that for all property access and scheduling, so the loop never touches a null/stale reference. --- frontend/src/ts/test/pace-caret.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 4a3b861d79f4..da0b4d01cd40 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -130,7 +130,12 @@ export async function init(): Promise { } export async function update(expectedStepEnd: number): Promise { - if (settings === null || !TestState.isActive || TestState.resultVisible) { + const currentSettings = settings; + if ( + currentSettings === null || + !TestState.isActive || + TestState.resultVisible + ) { return; } @@ -146,8 +151,8 @@ export async function update(expectedStepEnd: number): Promise { const duration = absoluteStepEnd - now; caret.goTo({ - wordIndex: settings.currentWordIndex, - letterIndex: settings.currentLetterIndex, + wordIndex: currentSettings.currentWordIndex, + letterIndex: currentSettings.currentLetterIndex, isLanguageRightToLeft: TestState.isLanguageRightToLeft, isDirectionReversed: TestState.isDirectionReversed, animate: true, @@ -157,12 +162,14 @@ export async function update(expectedStepEnd: number): Promise { }, }); - // Normal case - schedule next step - settings.timeout = setTimeout( + currentSettings.timeout = setTimeout( () => { - update(expectedStepEnd + (settings?.spc ?? 0) * 1000).catch(() => { - settings = null; - }); + if (settings !== currentSettings) return; + update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch( + () => { + if (settings === currentSettings) settings = null; + }, + ); }, Math.max(0, duration), ); From 331ca1a26faeb3d78f631014b027a6b2a1ead78e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sun, 14 Dec 2025 00:36:27 +0100 Subject: [PATCH 3/3] build: restore vendor.css (@fehmer) (#7235) --- frontend/vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index aee372bafc49..1c3bc2a98bc3 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -190,7 +190,7 @@ function getPlugins({ UnpluginInjectPreload({ files: [ { - outputMatch: /css\/vendor.*\.css$/, + outputMatch: /css\/.*\.css$/, attributes: { as: "style", type: "text/css", @@ -246,6 +246,10 @@ function getBuildOptions({ if (/\.(woff|woff2|eot|ttf|otf)$/.test(assetInfo.name)) { return `webfonts/[name]-[hash].${extType}`; } + if (assetInfo.name === "misc.css") { + return `${extType}/vendor.[hash][extname]`; + } + return `${extType}/[name].[hash][extname]`; }, chunkFileNames: "js/[name].[hash].js",