From becb40f78bc4db8997b6f589b84b3a36a052750c Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 26 Nov 2025 17:35:10 +0100 Subject: [PATCH 01/27] perf --- frontend/src/styles/test.scss | 2 +- frontend/src/ts/elements/keymap.ts | 119 ++--- frontend/src/ts/elements/monkey-power.ts | 102 ++--- frontend/src/ts/sentry.ts | 7 +- frontend/src/ts/test/live-acc.ts | 81 ++-- frontend/src/ts/test/live-burst.ts | 75 ++-- frontend/src/ts/test/live-speed.ts | 79 ++-- frontend/src/ts/test/monkey.ts | 4 +- frontend/src/ts/test/test-logic.ts | 1 + frontend/src/ts/test/test-timer.ts | 161 ++++--- frontend/src/ts/test/test-ui.ts | 524 +++++++++++++---------- frontend/src/ts/test/timer-progress.ts | 253 +++++------ 12 files changed, 788 insertions(+), 620 deletions(-) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 49587b8b2c01..82a1a2d62934 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -584,7 +584,7 @@ } #wordsInput { - width: 1ch; + width: 0; font-size: 1em; height: 1em; opacity: 0; diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index 2ba5d4f93d21..eccd43820ca1 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -16,6 +16,7 @@ import { getActiveFunboxNames } from "../test/funbox/list"; import { areSortedArraysEqual } from "../utils/arrays"; import { LayoutObject } from "@monkeytype/schemas/layouts"; import { animate } from "animejs"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; export const keyDataDelimiter = "~~"; @@ -60,74 +61,78 @@ const stenoKeys: LayoutObject = { function highlightKey(currentKey: string): void { if (Config.mode === "zen") return; - if (currentKey === "") currentKey = " "; - try { - $(".activeKey").removeClass("activeKey"); - - let highlightKey; - if (Config.language.startsWith("korean")) { - currentKey = Hangul.disassemble(currentKey)[0] ?? currentKey; - } - if (currentKey === " ") { - highlightKey = "#keymap .keySpace"; - } else if (currentKey === '"') { - highlightKey = `#keymap .keymapKey[data-key*='${currentKey}']`; - } else { - highlightKey = `#keymap .keymapKey[data-key*="${currentKey}"]`; - } + requestDebouncedAnimationFrame("keymap.highlightKey", async () => { + if (currentKey === "") currentKey = " "; + try { + document + .querySelectorAll(".activeKey") + .forEach((el) => el.classList.remove("activeKey")); - // console.log("highlighting", highlightKey); + let highlightKey; + if (Config.language.startsWith("korean")) { + currentKey = Hangul.disassemble(currentKey)[0] ?? currentKey; + } + if (currentKey === " ") { + highlightKey = "#keymap .keySpace"; + } else if (currentKey === '"') { + highlightKey = `#keymap .keymapKey[data-key*='${currentKey}']`; + } else { + highlightKey = `#keymap .keymapKey[data-key*="${currentKey}"]`; + } - $(highlightKey).addClass("activeKey"); - } catch (e) { - if (e instanceof Error) { - console.log("could not update highlighted keymap key: " + e.message); + document.querySelector(highlightKey)?.classList.add("activeKey"); + } catch (e) { + if (e instanceof Error) { + console.log("could not update highlighted keymap key: " + e.message); + } } - } + }); } async function flashKey(key: string, correct?: boolean): Promise { - if (key === undefined) return; - //console.log("key", key); - if (key === " ") { - key = "#keymap .keySpace"; - } else if (key === '"') { - key = `#keymap .keymapKey[data-key*='${key}']`; - } else { - key = `#keymap .keymapKey[data-key*="${key}"]`; - } + requestDebouncedAnimationFrame(`keymap.flashKey.${key}`, async () => { + if (key === undefined) return; + //console.log("key", key); + if (key === " ") { + key = "#keymap .keySpace"; + } else if (key === '"') { + key = `#keymap .keymapKey[data-key*='${key}']`; + } else { + key = `#keymap .keymapKey[data-key*="${key}"]`; + } - const themecolors = await ThemeColors.getAll(); + const themecolors = await ThemeColors.getAll(); - try { - let startingStyle = { - color: themecolors.bg, - backgroundColor: themecolors.sub, - borderColor: themecolors.sub, - }; - - if (correct || Config.blindMode) { - startingStyle = { - color: themecolors.bg, - backgroundColor: themecolors.main, - borderColor: themecolors.main, - }; - } else { - startingStyle = { + try { + let startingStyle = { color: themecolors.bg, - backgroundColor: themecolors.error, - borderColor: themecolors.error, + backgroundColor: themecolors.sub, + borderColor: themecolors.sub, }; - } - animate(key, { - color: [startingStyle.color, themecolors.sub], - backgroundColor: [startingStyle.backgroundColor, themecolors.subAlt], - borderColor: [startingStyle.borderColor, themecolors.sub], - duration: 250, - easing: "out(5)", - }); - } catch (e) {} + if (correct || Config.blindMode) { + startingStyle = { + color: themecolors.bg, + backgroundColor: themecolors.main, + borderColor: themecolors.main, + }; + } else { + startingStyle = { + color: themecolors.bg, + backgroundColor: themecolors.error, + borderColor: themecolors.error, + }; + } + + animate(key, { + color: [startingStyle.color, themecolors.sub], + backgroundColor: [startingStyle.backgroundColor, themecolors.subAlt], + borderColor: [startingStyle.borderColor, themecolors.sub], + duration: 250, + easing: "out(5)", + }); + } catch (e) {} + }); } export function hide(): void { diff --git a/frontend/src/ts/elements/monkey-power.ts b/frontend/src/ts/elements/monkey-power.ts index 7904caef6f26..7564f89df3c7 100644 --- a/frontend/src/ts/elements/monkey-power.ts +++ b/frontend/src/ts/elements/monkey-power.ts @@ -2,6 +2,10 @@ import * as ThemeColors from "./theme-colors"; import * as SlowTimer from "../states/slow-timer"; import Config from "../config"; import { isSafeNumber } from "@monkeytype/util/numbers"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; + +const html = document.querySelector("html") as HTMLElement; +const body = document.body; type Particle = { x: number; @@ -14,7 +18,7 @@ type Particle = { type CTX = { particles: Particle[]; - caret?: JQuery; + caret?: HTMLElement; canvas?: HTMLCanvasElement; context2d?: CanvasRenderingContext2D; rendering: boolean; @@ -118,7 +122,7 @@ function updateParticle(particle: Particle): void { } export function init(): void { - ctx.caret = $("#caret"); + ctx.caret = document.querySelector("#caret") as HTMLElement; ctx.canvas = createCanvas(); ctx.context2d = ctx.canvas.getContext("2d") as CanvasRenderingContext2D; } @@ -155,7 +159,7 @@ function render(): void { } ctx.particles = keep; - if (ctx.particles.length && !SlowTimer.get()) { + if (ctx.particles.length) { requestAnimationFrame(render); } else { ctx.context2d.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); @@ -168,14 +172,13 @@ export function reset(immediate = false): void { delete ctx.resetTimeOut; clearTimeout(ctx.resetTimeOut); - const body = $(document.body); - body.css("transition", "all .25s, transform 0.8s"); - body.css("transform", `translate(0,0)`); + body.style.transition = "all .25s, transform 0.8s"; + body.style.transform = `translate(0,0)`; setTimeout( () => { - body.css("transition", "all .25s, transform .05s"); - $("html").css("overflow", "inherit"); - $("html").css("overflow-y", "scroll"); + body.style.transition = "all .25s, transform .05s"; + html.style.overflow = "inherit"; + html.style.overflowY = "scroll"; }, immediate ? 0 : 1000 ); @@ -201,47 +204,46 @@ function randomColor(): string { export async function addPower(good = true, extra = false): Promise { if (Config.monkeyPowerLevel === "off" || SlowTimer.get()) return; - if (Config.blindMode) good = true; - - // Shake - if (["3", "4"].includes(Config.monkeyPowerLevel)) { - $("html").css("overflow", "hidden"); - const shake = [ - Math.round(shakeAmount - Math.random() * shakeAmount), - Math.round(shakeAmount - Math.random() * shakeAmount), + requestDebouncedAnimationFrame("monkey-power.addPower", async () => { + if (Config.blindMode) good = true; + + // Shake + if (["3", "4"].includes(Config.monkeyPowerLevel)) { + html.style.overflow = "hidden"; + const shake = [ + Math.round(shakeAmount - Math.random() * shakeAmount), + Math.round(shakeAmount - Math.random() * shakeAmount), + ]; + body.style.transform = `translate(${shake[0]}px, ${shake[1]}px)`; + if (isSafeNumber(ctx.resetTimeOut)) clearTimeout(ctx.resetTimeOut); + ctx.resetTimeOut = setTimeout(reset, 2000) as unknown as number; + } + + // Sparks + const offset = ctx.caret?.getBoundingClientRect(); + const coords = [ + offset?.left ?? 0, + (offset?.top ?? 0) + (ctx.caret?.offsetHeight ?? 0), ]; - $(document.body).css( - "transform", - `translate(${shake[0]}px, ${shake[1]}px)` - ); - if (isSafeNumber(ctx.resetTimeOut)) clearTimeout(ctx.resetTimeOut); - ctx.resetTimeOut = setTimeout(reset, 2000) as unknown as number; - } - // Sparks - const offset = ctx.caret?.offset(); - const coords = [ - offset?.left ?? 0, - (offset?.top ?? 0) + (ctx.caret?.height() ?? 0), - ]; - - for ( - let i = Math.round( - (particleCreateCount[0] + Math.random() * particleCreateCount[1]) * - (extra ? 2 : 1) - ); - i > 0; - i-- - ) { - const color = ["2", "4"].includes(Config.monkeyPowerLevel) - ? randomColor() - : good - ? await ThemeColors.get("caret") - : await ThemeColors.get("error"); - ctx.particles.push( - createParticle(...(coords as [x: number, y: number]), color) - ); - } - - startRender(); + for ( + let i = Math.round( + (particleCreateCount[0] + Math.random() * particleCreateCount[1]) * + (extra ? 2 : 1) + ); + i > 0; + i-- + ) { + const color = ["2", "4"].includes(Config.monkeyPowerLevel) + ? randomColor() + : good + ? await ThemeColors.get("caret") + : await ThemeColors.get("error"); + ctx.particles.push( + createParticle(...(coords as [x: number, y: number]), color) + ); + } + + startRender(); + }); } diff --git a/frontend/src/ts/sentry.ts b/frontend/src/ts/sentry.ts index d07fc8680272..f775b142a485 100644 --- a/frontend/src/ts/sentry.ts +++ b/frontend/src/ts/sentry.ts @@ -26,9 +26,10 @@ export async function activateSentry(): Promise { environment: envConfig.isDevelopment ? "development" : "production", integrations: [ Sentry.browserTracingIntegration(), - // Sentry.replayIntegration({ - // unmask: ["#notificationCenter"], - // block: ["#commandLine .modal .suggestions"], + // // Sentry.replayIntegration({ + // // unmask: ["#notificationCenter"], + // // block: ["#commandLine .modal .suggestions"], + // // // ignore: ["#wordsInput"], // }), Sentry.thirdPartyErrorFilterIntegration({ filterKeys: ["monkeytype-frontend"], diff --git a/frontend/src/ts/test/live-acc.ts b/frontend/src/ts/test/live-acc.ts index ac0df78df148..d9c368bfb247 100644 --- a/frontend/src/ts/test/live-acc.ts +++ b/frontend/src/ts/test/live-acc.ts @@ -3,6 +3,7 @@ import * as TestState from "../test/test-state"; import * as ConfigEvent from "../observables/config-event"; import { applyReducedMotion } from "../utils/misc"; import { animate } from "animejs"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; const textEl = document.querySelector( "#liveStatsTextBottom .liveAcc" @@ -10,17 +11,21 @@ const textEl = document.querySelector( const miniEl = document.querySelector("#liveStatsMini .acc") as Element; export function update(acc: number): void { - let number = Math.floor(acc); - if (Config.blindMode) { - number = 100; - } - miniEl.innerHTML = number + "%"; - textEl.innerHTML = number + "%"; + requestDebouncedAnimationFrame("live-acc.update", () => { + let number = Math.floor(acc); + if (Config.blindMode) { + number = 100; + } + miniEl.innerHTML = number + "%"; + textEl.innerHTML = number + "%"; + }); } export function reset(): void { - miniEl.innerHTML = "100%"; - textEl.innerHTML = "100%"; + requestDebouncedAnimationFrame("live-acc.reset", () => { + miniEl.innerHTML = "100%"; + textEl.innerHTML = "100%"; + }); } let state = false; @@ -29,39 +34,43 @@ export function show(): void { if (Config.liveAccStyle === "off") return; if (!TestState.isActive) return; if (state) return; - if (Config.liveAccStyle === "mini") { - miniEl.classList.remove("hidden"); - animate(miniEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - }); - } else { - textEl.classList.remove("hidden"); - animate(textEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - }); - } - state = true; + requestDebouncedAnimationFrame("live-acc.show", () => { + if (Config.liveAccStyle === "mini") { + miniEl.classList.remove("hidden"); + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } else { + textEl.classList.remove("hidden"); + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } + state = true; + }); } export function hide(): void { if (!state) return; - animate(textEl, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - textEl.classList.add("hidden"); - }, - }); - animate(miniEl, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - miniEl.classList.add("hidden"); - }, + requestDebouncedAnimationFrame("live-acc.hide", () => { + animate(textEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); + }, + }); + animate(miniEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); + }, + }); + state = false; }); - state = false; } ConfigEvent.subscribe((eventKey, eventValue) => { diff --git a/frontend/src/ts/test/live-burst.ts b/frontend/src/ts/test/live-burst.ts index 1c0d79831087..42df91e91402 100644 --- a/frontend/src/ts/test/live-burst.ts +++ b/frontend/src/ts/test/live-burst.ts @@ -4,6 +4,7 @@ import * as ConfigEvent from "../observables/config-event"; import Format from "../utils/format"; import { applyReducedMotion } from "../utils/misc"; import { animate } from "animejs"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; const textEl = document.querySelector( "#liveStatsTextBottom .liveBurst" @@ -11,14 +12,18 @@ const textEl = document.querySelector( const miniEl = document.querySelector("#liveStatsMini .burst") as Element; export function reset(): void { - textEl.innerHTML = "0"; - miniEl.innerHTML = "0"; + requestDebouncedAnimationFrame("live-burst.reset", () => { + textEl.innerHTML = "0"; + miniEl.innerHTML = "0"; + }); } export async function update(burst: number): Promise { - const burstText = Format.typingSpeed(burst, { showDecimalPlaces: false }); - miniEl.innerHTML = burstText; - textEl.innerHTML = burstText; + requestDebouncedAnimationFrame("live-burst.update", () => { + const burstText = Format.typingSpeed(burst, { showDecimalPlaces: false }); + miniEl.innerHTML = burstText; + textEl.innerHTML = burstText; + }); } let state = false; @@ -27,39 +32,43 @@ export function show(): void { if (Config.liveBurstStyle === "off") return; if (!TestState.isActive) return; if (state) return; - if (Config.liveBurstStyle === "mini") { - miniEl.classList.remove("hidden"); - animate(miniEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - }); - } else { - textEl.classList.remove("hidden"); - animate(textEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - }); - } - state = true; + requestDebouncedAnimationFrame("live-burst.show", () => { + if (Config.liveBurstStyle === "mini") { + miniEl.classList.remove("hidden"); + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } else { + textEl.classList.remove("hidden"); + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } + state = true; + }); } export function hide(): void { if (!state) return; - animate(textEl, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - textEl.classList.add("hidden"); - }, - }); - animate(miniEl, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - miniEl.classList.add("hidden"); - }, + requestDebouncedAnimationFrame("live-burst.hide", () => { + animate(textEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); + }, + }); + animate(miniEl, { + opacity: [1, 0], + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); + }, + }); + state = false; }); - state = false; } ConfigEvent.subscribe((eventKey, eventValue) => { diff --git a/frontend/src/ts/test/live-speed.ts b/frontend/src/ts/test/live-speed.ts index 415e7b5f51d6..df0e2d0eff90 100644 --- a/frontend/src/ts/test/live-speed.ts +++ b/frontend/src/ts/test/live-speed.ts @@ -3,6 +3,7 @@ import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; import Format from "../utils/format"; import { applyReducedMotion } from "../utils/misc"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { animate } from "animejs"; const textElement = document.querySelector( @@ -11,18 +12,22 @@ const textElement = document.querySelector( const miniElement = document.querySelector("#liveStatsMini .speed") as Element; export function reset(): void { - textElement.innerHTML = "0"; - miniElement.innerHTML = "0"; + requestDebouncedAnimationFrame("live-speed.reset", () => { + textElement.innerHTML = "0"; + miniElement.innerHTML = "0"; + }); } export function update(wpm: number, raw: number): void { - let number = wpm; - if (Config.blindMode) { - number = raw; - } - const numberText = Format.typingSpeed(number, { showDecimalPlaces: false }); - textElement.innerHTML = numberText; - miniElement.innerHTML = numberText; + requestDebouncedAnimationFrame("live-speed.update", () => { + let number = wpm; + if (Config.blindMode) { + number = raw; + } + const numberText = Format.typingSpeed(number, { showDecimalPlaces: false }); + textElement.innerHTML = numberText; + miniElement.innerHTML = numberText; + }); } let state = false; @@ -31,39 +36,43 @@ export function show(): void { if (Config.liveSpeedStyle === "off") return; if (!TestState.isActive) return; if (state) return; - if (Config.liveSpeedStyle === "mini") { - miniElement.classList.remove("hidden"); + requestDebouncedAnimationFrame("live-speed.show", () => { + if (Config.liveSpeedStyle === "mini") { + miniElement.classList.remove("hidden"); + animate(miniElement, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } else { + textElement.classList.remove("hidden"); + animate(textElement, { + opacity: [0, 1], + duration: applyReducedMotion(125), + }); + } + state = true; + }); +} + +export function hide(): void { + if (!state) return; + requestDebouncedAnimationFrame("live-speed.hide", () => { animate(miniElement, { - opacity: [0, 1], + opacity: [1, 0], duration: applyReducedMotion(125), + onComplete: () => { + miniElement.classList.add("hidden"); + }, }); - } else { - textElement.classList.remove("hidden"); animate(textElement, { - opacity: [0, 1], + opacity: [1, 0], duration: applyReducedMotion(125), + onComplete: () => { + textElement.classList.add("hidden"); + }, }); - } - state = true; -} - -export function hide(): void { - if (!state) return; - animate(miniElement, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - miniElement.classList.add("hidden"); - }, - }); - animate(textElement, { - opacity: [1, 0], - duration: applyReducedMotion(125), - onComplete: () => { - textElement.classList.add("hidden"); - }, + state = false; }); - state = false; } ConfigEvent.subscribe((eventKey, eventValue) => { diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index 8b20472638c5..89500353b608 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -5,6 +5,8 @@ import * as TestState from "../test/test-state"; import * as KeyConverter from "../utils/key-converter"; import { animate } from "animejs"; +const monkey = document.querySelector("#monkey") as HTMLElement; + ConfigEvent.subscribe((eventKey) => { if (eventKey === "monkey" && TestState.isActive) { if (Config.monkey) { @@ -71,7 +73,7 @@ export function updateFastOpacity(num: number): void { }); let animDuration = mapRange(num, 130, 180, 0.25, 0.01); if (animDuration === 0.25) animDuration = 0; - $("#monkey").css({ animationDuration: animDuration + "s" }); + monkey.style.animationDuration = animDuration + "s"; } export function type(event: JQuery.KeyDownEvent | KeyboardEvent): void { diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5e98d856b18b..42ac8c711504 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -926,6 +926,7 @@ export async function finish(difficultyFailed = false): Promise { TestUI.setResultCalculating(true); const now = performance.now(); TestStats.setEnd(now); + console.log(`Test time timer based: `, TestTimer.getTimerTime() / 1000); if (TestState.isRepeated && Config.mode === "quote") { TestState.setRepeated(false); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 56d0004f9243..b4fadf805075 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -4,7 +4,7 @@ import Config, * as UpdateConfig from "../config"; import * as CustomText from "./custom-text"; import * as TimerProgress from "./timer-progress"; -import * as LiveWpm from "./live-speed"; +import * as LiveSpeed from "./live-speed"; import * as TestStats from "./test-stats"; import * as TestInput from "./test-input"; import * as Monkey from "./monkey"; @@ -19,6 +19,28 @@ import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer import { KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import * as SoundController from "../controllers/sound-controller"; import { clearLowFpsMode, setLowFpsMode } from "../anim"; +import { createTimer } from "animejs"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; + +let lastLoop = 0; +const newTimer = createTimer({ + duration: 1000, + loop: true, + autoplay: false, + onBegin: () => { + lastLoop = performance.now(); + }, + onLoop: () => { + const drift = Math.abs(1000 - (performance.now() - lastLoop)); + lastLoop = performance.now(); + checkIfTimerIsSlow(drift); + timerStep(); + }, +}); + +export function getTimerTime(): number { + return newTimer.currentTime; +} type TimerStats = { dateNow: number; @@ -32,7 +54,7 @@ let timer: NodeJS.Timeout | null = null; const interval = 1000; let expected = 0; -let timerDebug = false; +let timerDebug = true; export function enableTimerDebug(): void { timerDebug = true; } @@ -40,6 +62,7 @@ export function enableTimerDebug(): void { export function clear(): void { clearLowFpsMode(); Time.set(0); + newTimer.reset(); if (timer !== null) clearTimeout(timer); } @@ -53,24 +76,10 @@ function premid(): void { if (timerDebug) console.timeEnd("premid"); } -function updateTimer(): void { - if (timerDebug) console.time("timer progress update"); - if ( - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") - ) { - TimerProgress.update(); - } - if (timerDebug) console.timeEnd("timer progress update"); -} - function calculateWpmRaw(): { wpm: number; raw: number } { if (timerDebug) console.time("calculate wpm and raw"); const wpmAndRaw = TestStats.calculateWpmAndRaw(); if (timerDebug) console.timeEnd("calculate wpm and raw"); - if (timerDebug) console.time("update live wpm"); - LiveWpm.update(wpmAndRaw.wpm, wpmAndRaw.raw); - if (timerDebug) console.timeEnd("update live wpm"); if (timerDebug) console.time("push to history"); TestInput.pushToWpmHistory(wpmAndRaw.wpm); TestInput.pushToRawHistory(wpmAndRaw.raw); @@ -206,22 +215,76 @@ export function getTimerStats(): TimerStats[] { return timerStats; } -async function timerStep(): Promise { +function timerStep(): void { if (timerDebug) console.time("timer step -----------------------------"); + + //calc Time.increment(); - premid(); - updateTimer(); - if (Config.playTimeWarning !== "off") playTimeWarning(); const wpmAndRaw = calculateWpmRaw(); const acc = calculateAcc(); - monkey(wpmAndRaw); + + //ui updates + requestDebouncedAnimationFrame("test-timer.timerStep", () => { + premid(); + monkey(wpmAndRaw); + }); + + // already using raf + TimerProgress.update(); + LiveSpeed.update(wpmAndRaw.wpm, wpmAndRaw.raw); + + //logic + if (Config.playTimeWarning !== "off") playTimeWarning(); layoutfluid(); const failed = checkIfFailed(wpmAndRaw, acc); if (!failed) checkIfTimeIsUp(); + if (timerDebug) console.timeEnd("timer step -----------------------------"); } +function checkIfTimerIsSlow(drift: number): void { + if ( + (Config.mode === "time" && Config.time < 130 && Config.time > 0) || + (Config.mode === "words" && Config.words < 250 && Config.words > 0) + ) { + if (drift > 125) { + //slow timer + SlowTimer.set(); + setLowFpsMode(); + } + if (drift > 250) { + slowTimerCount++; + } + + if (drift > 500 || slowTimerCount > 5) { + //slow timer + + Notifications.add( + 'This could be caused by "efficiency mode" on Microsoft Edge.' + ); + + Notifications.add( + "Stopping the test due to bad performance. This would cause test calculations to be incorrect. If this happens a lot, please report this.", + -1 + ); + + TimerEvent.dispatch("fail", "slow timer"); + } + } +} + export async function start(): Promise { + void _startNew(); + // void _startOld(); +} + +async function _startNew(): Promise { + SlowTimer.clear(); + slowTimerCount = 0; + newTimer.play(); +} + +async function _startOld(): Promise { SlowTimer.clear(); slowTimerCount = 0; timerStats = []; @@ -234,33 +297,33 @@ export async function start(): Promise { expected: expected, nextDelay: delay, }); - if ( - (Config.mode === "time" && Config.time < 130 && Config.time > 0) || - (Config.mode === "words" && Config.words < 250 && Config.words > 0) - ) { - if (delay < interval / 2) { - //slow timer - SlowTimer.set(); - setLowFpsMode(); - } - if (delay < interval / 10) { - slowTimerCount++; - if (slowTimerCount > 5) { - //slow timer - - Notifications.add( - 'This could be caused by "efficiency mode" on Microsoft Edge.' - ); - - Notifications.add( - "Stopping the test due to bad performance. This would cause test calculations to be incorrect. If this happens a lot, please report this.", - -1 - ); - - TimerEvent.dispatch("fail", "slow timer"); - } - } - } + // if ( + // (Config.mode === "time" && Config.time < 130 && Config.time > 0) || + // (Config.mode === "words" && Config.words < 250 && Config.words > 0) + // ) { + // if (delay < interval / 2) { + // //slow timer + // SlowTimer.set(); + // setLowFpsMode(); + // } + // if (delay < interval / 10) { + // slowTimerCount++; + // if (slowTimerCount > 5) { + // //slow timer + + // Notifications.add( + // 'This could be caused by "efficiency mode" on Microsoft Edge.' + // ); + + // Notifications.add( + // "Stopping the test due to bad performance. This would cause test calculations to be incorrect. If this happens a lot, please report this.", + // -1 + // ); + + // TimerEvent.dispatch("fail", "slow timer"); + // } + // } + // } timer = setTimeout(function () { // time++; @@ -271,7 +334,7 @@ export async function start(): Promise { return; } - void timerStep(); + timerStep(); expected += interval; loop(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index e2ee27190b5f..baa536595a8d 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -105,7 +105,12 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => { if (eventValue === undefined) return; if (eventKey === "highlightMode") { - if (ActivePage.get() === "test") updateActiveElement(); + if (ActivePage.get() === "test") + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); } if ( @@ -202,34 +207,53 @@ export function getActiveWordElement(): HTMLElement | null { } export function updateActiveElement( - backspace?: boolean, - initial = false + options: + | { direction: "forward" | "back"; initial?: undefined } + | { direction?: undefined; initial: true } ): void { - const active = document.querySelector("#words .active"); - if (!backspace) { - active?.classList.add("typed"); - } - if (Config.mode === "zen" && backspace) { - active?.remove(); - } else if (active !== null && !initial) { - active.classList.remove("active"); - } - const newActiveWord = getActiveWordElement(); - if (newActiveWord === null) { - throw new Error("activeWord is null - can't update active element"); - } + requestDebouncedAnimationFrame("test-ui.updateActiveElement", () => { + const { direction, initial } = options; + + let previousActiveWordTop: number | null = null; + if (initial === undefined) { + const previousActiveWord = document.querySelector( + "#words .active" + ) as HTMLElement; + if (direction === "forward") { + previousActiveWord.classList.add("typed"); + } else if (direction === "back") { + if (Config.mode === "zen") { + previousActiveWord.remove(); + } + } + previousActiveWord.classList.remove("active"); + previousActiveWordTop = previousActiveWord.offsetTop; + } - newActiveWord.classList.add("active"); - newActiveWord.classList.remove("error"); - newActiveWord.classList.remove("typed"); + const newActiveWord = getActiveWordElement(); + if (newActiveWord === null) { + throw new Error("activeWord is null - can't update active element"); + } - activeWordTop = newActiveWord.offsetTop; + newActiveWord.classList.add("active"); + newActiveWord.classList.remove("error"); + newActiveWord.classList.remove("typed"); - updateWordsInputPosition(); + activeWordTop = newActiveWord.offsetTop; - if (!initial && Config.tapeMode !== "off") { - void scrollTape(); - } + updateWordsInputPosition(); + + if (!initial && Config.tapeMode !== "off") { + void scrollTape(); + } + + if (!Config.showAllLines && previousActiveWordTop !== null) { + const newActiveWordTop = newActiveWord.offsetTop; + if (newActiveWordTop > previousActiveWordTop) { + void lineJump(previousActiveWordTop); + } + } + }); } function createHintsHtml( @@ -499,7 +523,9 @@ export function showWords(): void { words.html(wordsHTML); } - updateActiveElement(undefined, true); + updateActiveElement({ + initial: true, + }); updateWordWrapperClasses(); PaceCaret.resetCaretPosition(); } @@ -513,56 +539,54 @@ export function appendEmptyWordElement( } export function updateWordsInputPosition(): void { - requestDebouncedAnimationFrame("test-ui.updateWordsInputPosition", () => { - if (ActivePage.get() !== "test") return; - const isTestRightToLeft = TestState.isDirectionReversed - ? !TestState.isLanguageRightToLeft - : TestState.isLanguageRightToLeft; + if (ActivePage.get() !== "test") return; + const isTestRightToLeft = TestState.isDirectionReversed + ? !TestState.isLanguageRightToLeft + : TestState.isLanguageRightToLeft; - const el = getInputElement(); - const wrapperElement = document.querySelector("#wordsWrapper"); + const el = getInputElement(); + const wrapperElement = document.querySelector("#wordsWrapper"); - if (el === null || wrapperElement === null) return; + if (el === null || wrapperElement === null) return; - const activeWord = getActiveWordElement(); + const activeWord = getActiveWordElement(); - if (!activeWord) { - el.style.top = "0px"; - el.style.left = "0px"; - return; - } + if (!activeWord) { + el.style.top = "0px"; + el.style.left = "0px"; + return; + } - const letterHeight = convertRemToPixels(Config.fontSize); - const targetTop = - activeWord.offsetTop + letterHeight / 2 - el.offsetHeight / 2 + 1; //+1 for half of border + const letterHeight = convertRemToPixels(Config.fontSize); + const targetTop = + activeWord.offsetTop + letterHeight / 2 - el.offsetHeight / 2 + 1; //+1 for half of border - if (Config.tapeMode !== "off") { - el.style.maxWidth = `${100 - Config.tapeMargin}%`; - } else { - el.style.maxWidth = ""; - } - if (activeWord.offsetWidth < letterHeight) { - el.style.width = letterHeight + "px"; - } else { - el.style.width = activeWord.offsetWidth + "px"; - } + if (Config.tapeMode !== "off") { + el.style.maxWidth = `${100 - Config.tapeMargin}%`; + } else { + el.style.maxWidth = ""; + } + if (activeWord.offsetWidth < letterHeight) { + el.style.width = letterHeight + "px"; + } else { + el.style.width = activeWord.offsetWidth + "px"; + } - el.style.top = targetTop + "px"; + el.style.top = targetTop + "px"; - if (Config.tapeMode !== "off") { - el.style.left = `${ - wrapperElement.offsetWidth * (Config.tapeMargin / 100) - }px`; + if (Config.tapeMode !== "off") { + el.style.left = `${ + wrapperElement.offsetWidth * (Config.tapeMargin / 100) + }px`; + } else { + if (activeWord.offsetWidth < letterHeight && isTestRightToLeft) { + el.style.left = activeWord.offsetLeft - letterHeight + "px"; } else { - if (activeWord.offsetWidth < letterHeight && isTestRightToLeft) { - el.style.left = activeWord.offsetLeft - letterHeight + "px"; - } else { - el.style.left = Math.max(0, activeWord.offsetLeft) + "px"; - } + el.style.left = Math.max(0, activeWord.offsetLeft) + "px"; } + } - keepWordsInputInTheCenter(); - }); + keepWordsInputInTheCenter(); } let centeringActiveLine: Promise = Promise.resolve(); @@ -688,7 +712,9 @@ export function addWord( word: string, wordIndex = TestWords.words.length - 1 ): void { - $("#words").append(buildWordHTML(word, wordIndex)); + requestAnimationFrame(() => { + $("#words").append(buildWordHTML(word, wordIndex)); + }); } export function flipColors(tf: boolean): void { @@ -707,151 +733,182 @@ export function colorful(tc: boolean): void { } } -export async function updateActiveWordLetters( - inputOverride?: string -): Promise { - const input = inputOverride ?? TestInput.input.current; - const currentWord = TestWords.words.getCurrent(); - if (!currentWord && Config.mode !== "zen") return; - let ret = ""; - const activeWord = getActiveWordElement(); - if (!activeWord) return; - const hintIndices: number[][] = []; - - let newlineafter = false; +type UpdateActiveWordLettersParams = { + wordIndex: number; + input: string; + compositionData: string; +}; + +export async function updateWordLetters({ + wordIndex, + input, + compositionData, +}: UpdateActiveWordLettersParams): Promise { + requestDebouncedAnimationFrame( + `test-ui.updateWordLetters.${wordIndex}`, + async () => { + const currentWord = TestWords.words.get(wordIndex); + if (!currentWord && Config.mode !== "zen") return; + let ret = ""; + const wordAtIndex = getWordElement(wordIndex); + if (!wordAtIndex) return; + const hintIndices: number[][] = []; + + let newlineafter = false; + + if (Config.mode === "zen") { + for (const char of input) { + if (char === "\t") { + ret += ``; + } else if (char === "\n") { + newlineafter = true; + ret += ``; + } else { + ret += `${char}`; + } + } + if (input === "" && compositionData === "") { + ret += ``; + } - if (Config.mode === "zen") { - for (const char of TestInput.input.current) { - if (char === "\t") { - ret += ``; - } else if (char === "\n") { - newlineafter = true; - ret += ``; + for (const char of compositionData) { + ret += `${char}`; + } } else { - ret += `${char}`; - } - } - if (TestInput.input.current === "" && CompositionState.getData() === "") { - ret += ``; - } + const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); + + const inputChars = Strings.splitIntoCharacters(input); + const currentWordChars = Strings.splitIntoCharacters(currentWord); + for (let i = 0; i < inputChars.length; i++) { + const charCorrect = currentWordChars[i] === inputChars[i]; + + let currentLetter = currentWordChars[i] as string; + let tabChar = ""; + let nlChar = ""; + if (funbox) { + const cl = funbox.functions.getWordHtml(currentLetter); + if (cl !== "") { + currentLetter = cl; + } + } else if (currentLetter === "\t") { + tabChar = "tabChar"; + currentLetter = ``; + } else if (currentLetter === "\n") { + nlChar = "nlChar"; + currentLetter = ``; + } - const compositionData = CompositionState.getData(); - for (const char of compositionData) { - ret += `${char}`; - } - } else { - const funbox = findSingleActiveFunboxWithFunction("getWordHtml"); - - const inputChars = Strings.splitIntoCharacters(input); - const currentWordChars = Strings.splitIntoCharacters(currentWord); - for (let i = 0; i < inputChars.length; i++) { - const charCorrect = currentWordChars[i] === inputChars[i]; - - let currentLetter = currentWordChars[i] as string; - let tabChar = ""; - let nlChar = ""; - if (funbox) { - const cl = funbox.functions.getWordHtml(currentLetter); - if (cl !== "") { - currentLetter = cl; + if (charCorrect) { + ret += `${currentLetter}`; + } else if (currentLetter === undefined) { + let letter = inputChars[i]; + if (letter === " ") { + letter = "_"; + } else if (letter === "\t") { + letter = ""; + } else if (letter === "\n") { + letter = + ""; + } + ret += `${letter}`; + } else { + ret += + `` + + (Config.indicateTypos === "replace" || + Config.indicateTypos === "both" + ? inputChars[i] === " " + ? "_" + : inputChars[i] + : currentLetter) + + ""; + if ( + Config.indicateTypos === "below" || + Config.indicateTypos === "both" + ) { + const lastBlock = hintIndices[hintIndices.length - 1]; + if (lastBlock && lastBlock[lastBlock.length - 1] === i - 1) + lastBlock.push(i); + else hintIndices.push([i]); + } + } } - } else if (currentLetter === "\t") { - tabChar = "tabChar"; - currentLetter = ``; - } else if (currentLetter === "\n") { - nlChar = "nlChar"; - currentLetter = ``; - } - if (charCorrect) { - ret += `${currentLetter}`; - } else if (currentLetter === undefined) { - let letter = inputChars[i]; - if (letter === " ") { - letter = "_"; - } else if (letter === "\t") { - letter = ""; - } else if (letter === "\n") { - letter = ""; + 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}`; } - ret += `${letter}`; - } else { - ret += - `` + - (Config.indicateTypos === "replace" || Config.indicateTypos === "both" - ? inputChars[i] === " " - ? "_" - : inputChars[i] - : currentLetter) + - ""; - if ( - Config.indicateTypos === "below" || - Config.indicateTypos === "both" + + for ( + let i = inputChars.length + compositionData.length; + i < currentWordChars.length; + i++ ) { - const lastBlock = hintIndices[hintIndices.length - 1]; - if (lastBlock && lastBlock[lastBlock.length - 1] === i - 1) - lastBlock.push(i); - else hintIndices.push([i]); + const currentLetter = currentWordChars[i]; + if (funbox?.functions?.getWordHtml) { + ret += funbox.functions.getWordHtml(currentLetter as string, true); + } else if (currentLetter === "\t") { + ret += ``; + } else if (currentLetter === "\n") { + ret += ``; + } else { + ret += `` + currentLetter + ""; + } } } - } - - 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; + wordAtIndex.innerHTML = ret; + + if (hintIndices?.length) { + const wordAtIndexLetters = wordAtIndex.querySelectorAll("letter"); + let hintsHtml; + if (Config.indicateTypos === "both") { + hintsHtml = createHintsHtml( + hintIndices, + wordAtIndexLetters, + currentWord + ); + } else { + hintsHtml = createHintsHtml(hintIndices, wordAtIndexLetters, input); + } + wordAtIndex.insertAdjacentHTML("beforeend", hintsHtml); + const hintElements = wordAtIndex.getElementsByTagName("hint"); + await joinOverlappingHints( + hintIndices, + wordAtIndexLetters, + hintElements + ); } - if (Config.indicateTypos === "replace") { - charToShow = compositionChar === " " ? "_" : compositionChar; + if (newlineafter) + wordAtIndex.insertAdjacentHTML( + "afterend", + "
" + ); + if (Config.tapeMode !== "off") { + void scrollTape(); } - - 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); - } else if (currentLetter === "\t") { - ret += ``; - } else if (currentLetter === "\n") { - ret += ``; - } else { - ret += `` + currentLetter + ""; + if (Config.mode === "zen") { + // because we block word jumps in before-insert-text + // this check only needs to happen in zen mode + if (!Config.showAllLines) { + const wordTopAfterUpdate = wordAtIndex.offsetTop; + if (wordTopAfterUpdate > activeWordTop) { + void lineJump(activeWordTop, true); + } + } } - } - } - - activeWord.innerHTML = ret; - - if (hintIndices?.length) { - const activeWordLetters = activeWord.querySelectorAll("letter"); - let hintsHtml; - if (Config.indicateTypos === "both") { - hintsHtml = createHintsHtml(hintIndices, activeWordLetters, currentWord); - } else { - hintsHtml = createHintsHtml(hintIndices, activeWordLetters, input); - } - activeWord.insertAdjacentHTML("beforeend", hintsHtml); - const hintElements = activeWord.getElementsByTagName("hint"); - await joinOverlappingHints(hintIndices, activeWordLetters, hintElements); - } - - if (newlineafter) - $("#words").append( - "
" - ); - if (Config.tapeMode !== "off") { - void scrollTape(); - } + } //end of raf + ); } // this is needed in tape mode because sometimes we want the newline character to appear above the next line @@ -1519,14 +1576,21 @@ export async function applyBurstHeatmap(): Promise { } export function highlightBadWord(index: number): void { - getWordElement(index)?.classList.add("error"); + requestDebouncedAnimationFrame(`test-ui.highlightBadWord[${index}]`, () => { + getWordElement(index)?.classList.add("error"); + }); } export function highlightAllLettersAsCorrect(wordIndex: number): void { - const letters = getWordElement(wordIndex)?.children; - for (const letter of letters ?? []) { - letter.classList.add("correct"); - } + requestDebouncedAnimationFrame( + `test-ui.highlightAllLettersAsCorrect[${wordIndex}]`, + () => { + const letters = getWordElement(wordIndex)?.children; + for (const letter of letters ?? []) { + letter.classList.add("correct"); + } + } + ); } function updateWordsWidth(): void { @@ -1682,26 +1746,33 @@ export function afterTestTextInput( // 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); - } + if (!increasedWordIndex) { + void updateWordLetters({ + input: inputOverride ?? TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); } afterAnyTestInput("textInput", correct); } export function afterTestCompositionUpdate(): void { - void updateActiveWordLetters(); + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); // correct needs to be true to get the normal click sound afterAnyTestInput("compositionUpdate", true); } export function afterTestDelete(): void { - void updateActiveWordLetters(); + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); afterAnyTestInput("delete", null); } @@ -1726,7 +1797,11 @@ export function beforeTestWordChange( nospaceEnabled || forceUpdateActiveWordLetters ) { - void updateActiveWordLetters(); + void updateWordLetters({ + input: TestInput.input.current, + wordIndex: TestState.activeWordIndex, + compositionData: CompositionState.getData(), + }); } if (direction === "forward") { @@ -1741,7 +1816,9 @@ export function beforeTestWordChange( export async function afterTestWordChange( direction: "forward" | "back" ): Promise { - updateActiveElement(); + updateActiveElement({ + direction, + }); Caret.updatePosition(); const lastBurst = TestInput.burstHistory[TestInput.burstHistory.length - 1]; @@ -1749,28 +1826,7 @@ export async function afterTestWordChange( 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") { const wordsChildren = [ diff --git a/frontend/src/ts/test/timer-progress.ts b/frontend/src/ts/test/timer-progress.ts index ffba8b5572b0..d7bae1912d56 100644 --- a/frontend/src/ts/test/timer-progress.ts +++ b/frontend/src/ts/test/timer-progress.ts @@ -7,6 +7,7 @@ import * as Time from "../states/time"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; import { applyReducedMotion } from "../utils/misc"; +import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; import { animate } from "animejs"; const barEl = document.querySelector("#barTimerProgress .bar") as HTMLElement; @@ -20,70 +21,76 @@ const miniEl = document.querySelector("#liveStatsMini .time") as HTMLElement; export function show(): void { if (!TestState.isActive) return; - if (Config.mode !== "zen" && Config.timerStyle === "bar") { - animate(barOpacityEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - onBegin: () => { - barOpacityEl.classList.remove("hidden"); - }, - }); - } else if (Config.timerStyle === "text") { - animate(textEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - onBegin: () => { - textEl.classList.remove("hidden"); - }, - }); - } else if (Config.mode === "zen" || Config.timerStyle === "mini") { - animate(miniEl, { - opacity: [0, 1], - duration: applyReducedMotion(125), - onBegin: () => { - miniEl.classList.remove("hidden"); - }, - }); - } + requestDebouncedAnimationFrame("timer-progress.show", () => { + if (Config.mode !== "zen" && Config.timerStyle === "bar") { + animate(barOpacityEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + barOpacityEl.classList.remove("hidden"); + }, + }); + } else if (Config.timerStyle === "text") { + animate(textEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + textEl.classList.remove("hidden"); + }, + }); + } else if (Config.mode === "zen" || Config.timerStyle === "mini") { + animate(miniEl, { + opacity: [0, 1], + duration: applyReducedMotion(125), + onBegin: () => { + miniEl.classList.remove("hidden"); + }, + }); + } + }); } export function reset(): void { - let width = "0vw"; - if ( - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") - ) { - width = "100vw"; - } + requestDebouncedAnimationFrame("timer-progress.reset", () => { + let width = "0vw"; + if ( + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimitMode() === "time") + ) { + width = "100vw"; + } - animate(barEl, { - width, - duration: 0, + animate(barEl, { + width, + duration: 0, + }); + miniEl.textContent = "0"; + textEl.textContent = "0"; }); - miniEl.textContent = "0"; - textEl.textContent = "0"; } export function hide(): void { - animate(barOpacityEl, { - opacity: 0, - duration: applyReducedMotion(125), - }); + requestDebouncedAnimationFrame("timer-progress.hide", () => { + animate(barOpacityEl, { + opacity: 0, + duration: applyReducedMotion(125), + }); - animate(miniEl, { - opacity: 0, - duration: applyReducedMotion(125), - onComplete: () => { - miniEl.classList.add("hidden"); - }, - }); + animate(miniEl, { + opacity: 0, + duration: applyReducedMotion(125), + onComplete: () => { + miniEl.classList.add("hidden"); + }, + }); - animate(textEl, { - opacity: 0, - duration: applyReducedMotion(125), - onComplete: () => { - textEl.classList.add("hidden"); - }, + animate(textEl, { + opacity: 0, + duration: applyReducedMotion(125), + onComplete: () => { + textEl.classList.add("hidden"); + }, + }); }); } @@ -99,84 +106,88 @@ function getCurrentCount(): number { } export function update(): void { - const time = Time.get(); - if ( - Config.mode === "time" || - (Config.mode === "custom" && CustomText.getLimitMode() === "time") - ) { - let maxtime = Config.time; - if (Config.mode === "custom" && CustomText.getLimitMode() === "time") { - maxtime = CustomText.getLimitValue(); - } - if (Config.timerStyle === "bar") { - const percent = 100 - ((time + 1) / maxtime) * 100; + requestDebouncedAnimationFrame("timer-progress.update", () => { + const time = Time.get(); + if ( + Config.mode === "time" || + (Config.mode === "custom" && CustomText.getLimitMode() === "time") + ) { + let maxtime = Config.time; + if (Config.mode === "custom" && CustomText.getLimitMode() === "time") { + maxtime = CustomText.getLimitValue(); + } + if (Config.timerStyle === "bar") { + const percent = 100 - ((time + 1) / maxtime) * 100; - animate(barEl, { - width: percent + "vw", - duration: 1000, - ease: "linear", - }); - } else if (Config.timerStyle === "text") { - let displayTime = DateTime.secondsToString(maxtime - time); - if (maxtime === 0) { - displayTime = DateTime.secondsToString(time); + animate(barEl, { + width: percent + "vw", + duration: 1000, + ease: "linear", + }); + } else if (Config.timerStyle === "text") { + let displayTime = DateTime.secondsToString(maxtime - time); + if (maxtime === 0) { + displayTime = DateTime.secondsToString(time); + } + if (textEl !== null) { + textEl.innerHTML = "
" + displayTime + "
"; + } + } else if (Config.timerStyle === "mini") { + let displayTime = DateTime.secondsToString(maxtime - time); + if (maxtime === 0) { + displayTime = DateTime.secondsToString(time); + } + if (miniEl !== null) { + miniEl.innerHTML = displayTime; + } } - if (textEl !== null) { - textEl.innerHTML = "
" + displayTime + "
"; + } else if ( + Config.mode === "words" || + Config.mode === "custom" || + Config.mode === "quote" + ) { + let outof = TestWords.words.length; + if (Config.mode === "words") { + outof = Config.words; } - } else if (Config.timerStyle === "mini") { - let displayTime = DateTime.secondsToString(maxtime - time); - if (maxtime === 0) { - displayTime = DateTime.secondsToString(time); + if (Config.mode === "custom") { + outof = CustomText.getLimitValue(); } - if (miniEl !== null) { - miniEl.innerHTML = displayTime; + if (Config.mode === "quote") { + outof = TestWords.currentQuote?.textSplit.length ?? 1; } - } - } else if ( - Config.mode === "words" || - Config.mode === "custom" || - Config.mode === "quote" - ) { - let outof = TestWords.words.length; - if (Config.mode === "words") { - outof = Config.words; - } - if (Config.mode === "custom") { - outof = CustomText.getLimitValue(); - } - if (Config.mode === "quote") { - outof = TestWords.currentQuote?.textSplit.length ?? 1; - } - if (Config.timerStyle === "bar") { - const percent = Math.floor( - ((TestState.activeWordIndex + 1) / outof) * 100 - ); + if (Config.timerStyle === "bar") { + const percent = Math.floor( + ((TestState.activeWordIndex + 1) / outof) * 100 + ); - animate(barEl, { - width: percent + "vw", - duration: 250, - }); - } else if (Config.timerStyle === "text") { - if (outof === 0) { + animate(barEl, { + width: percent + "vw", + duration: 250, + }); + } else if (Config.timerStyle === "text") { + if (outof === 0) { + textEl.innerHTML = `
${ + TestInput.input.getHistory().length + }
`; + } else { + textEl.innerHTML = `
${getCurrentCount()}/${outof}
`; + } + } else if (Config.timerStyle === "mini") { + if (outof === 0) { + miniEl.innerHTML = `${TestInput.input.getHistory().length}`; + } else { + miniEl.innerHTML = `${getCurrentCount()}/${outof}`; + } + } + } else if (Config.mode === "zen") { + if (Config.timerStyle === "text") { textEl.innerHTML = `
${TestInput.input.getHistory().length}
`; } else { - textEl.innerHTML = `
${getCurrentCount()}/${outof}
`; - } - } else if (Config.timerStyle === "mini") { - if (outof === 0) { miniEl.innerHTML = `${TestInput.input.getHistory().length}`; - } else { - miniEl.innerHTML = `${getCurrentCount()}/${outof}`; } } - } else if (Config.mode === "zen") { - if (Config.timerStyle === "text") { - textEl.innerHTML = `
${TestInput.input.getHistory().length}
`; - } else { - miniEl.innerHTML = `${TestInput.input.getHistory().length}`; - } - } + }); } export function updateStyle(): void { From b20128112d5324010e0a93210cd29ce181d89c42 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 26 Nov 2025 17:42:59 +0100 Subject: [PATCH 02/27] fix: ellipsis not working correctly closes #7156 closes #7149 --- frontend/src/ts/input/handlers/insert-text.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index 953d0938ae46..a3e09211b483 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -62,12 +62,12 @@ type OnInsertTextParams = { 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 - const { inputValue } = getInputElementValue(); setInputElementValue(inputValue.slice(0, -options.data.length)); for (let i = 0; i < options.data.length; i++) { const char = options.data[i] as string; @@ -88,6 +88,10 @@ export async function onInsertText(options: OnInsertTextParams): Promise { TestWords.words.getCurrent()[TestInput.input.current.length] !== options.data ) { + // replace the data with the override + setInputElementValue( + inputValue.slice(0, -options.data.length) + charOverride + ); await onInsertText({ ...options, data: charOverride, From 3baf5e911c95c9d3f9525daa24602f7de954c9b6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 26 Nov 2025 18:00:46 +0100 Subject: [PATCH 03/27] use func --- frontend/src/ts/test/test-timer.ts | 31 ++---------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index b4fadf805075..5500b2bb5e76 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -297,36 +297,9 @@ async function _startOld(): Promise { expected: expected, nextDelay: delay, }); - // if ( - // (Config.mode === "time" && Config.time < 130 && Config.time > 0) || - // (Config.mode === "words" && Config.words < 250 && Config.words > 0) - // ) { - // if (delay < interval / 2) { - // //slow timer - // SlowTimer.set(); - // setLowFpsMode(); - // } - // if (delay < interval / 10) { - // slowTimerCount++; - // if (slowTimerCount > 5) { - // //slow timer - - // Notifications.add( - // 'This could be caused by "efficiency mode" on Microsoft Edge.' - // ); - - // Notifications.add( - // "Stopping the test due to bad performance. This would cause test calculations to be incorrect. If this happens a lot, please report this.", - // -1 - // ); - - // TimerEvent.dispatch("fail", "slow timer"); - // } - // } - // } + const drift = Math.abs(interval - delay); + checkIfTimerIsSlow(drift); timer = setTimeout(function () { - // time++; - if (!TestState.isActive) { if (timer !== null) clearTimeout(timer); SlowTimer.clear(); From a57724ee4cf623a8c7d85195724f5b4b14905d8f Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 13:52:24 +0100 Subject: [PATCH 04/27] raf keymap --- frontend/src/ts/elements/keymap.ts | 63 +++++++++++++++--------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index a914a6849697..66f7557e7d5a 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -96,43 +96,44 @@ function highlightKey(currentKey: string): void { async function flashKey(key: string, correct?: boolean): Promise { if (key === undefined) return; + requestDebouncedAnimationFrame(`keymap.flashKey.${key}`, async () => { + const $target = findKeyElements(key); - const $target = findKeyElements(key); + const elements = $target.toArray(); + if (elements.length === 0) return; - const elements = $target.toArray(); - if (elements.length === 0) return; + const themecolors = await ThemeColors.getAll(); - const themecolors = await ThemeColors.getAll(); - - try { - let startingStyle = { - color: themecolors.bg, - backgroundColor: themecolors.sub, - borderColor: themecolors.sub, - }; - - if (correct || Config.blindMode) { - startingStyle = { - color: themecolors.bg, - backgroundColor: themecolors.main, - borderColor: themecolors.main, - }; - } else { - startingStyle = { + try { + let startingStyle = { color: themecolors.bg, - backgroundColor: themecolors.error, - borderColor: themecolors.error, + backgroundColor: themecolors.sub, + borderColor: themecolors.sub, }; - } - animate(elements, { - color: [startingStyle.color, themecolors.sub], - backgroundColor: [startingStyle.backgroundColor, themecolors.subAlt], - borderColor: [startingStyle.borderColor, themecolors.sub], - duration: 250, - easing: "out(5)", - }); - } catch (e) {} + if (correct || Config.blindMode) { + startingStyle = { + color: themecolors.bg, + backgroundColor: themecolors.main, + borderColor: themecolors.main, + }; + } else { + startingStyle = { + color: themecolors.bg, + backgroundColor: themecolors.error, + borderColor: themecolors.error, + }; + } + + animate(elements, { + color: [startingStyle.color, themecolors.sub], + backgroundColor: [startingStyle.backgroundColor, themecolors.subAlt], + borderColor: [startingStyle.borderColor, themecolors.sub], + duration: 250, + easing: "out(5)", + }); + } catch (e) {} + }); } export function hide(): void { From aa4973535da330af34929b0d8a754cbe89459c92 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 13:52:34 +0100 Subject: [PATCH 05/27] optimize monkey --- frontend/src/ts/test/monkey.ts | 40 ++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/frontend/src/ts/test/monkey.ts b/frontend/src/ts/test/monkey.ts index f19c16bdd3f0..82614317300f 100644 --- a/frontend/src/ts/test/monkey.ts +++ b/frontend/src/ts/test/monkey.ts @@ -5,14 +5,15 @@ import * as TestState from "../test/test-state"; import * as KeyConverter from "../utils/key-converter"; import { animate } from "animejs"; -const monkey = document.querySelector("#monkey") as HTMLElement; +const monkeyEl = document.querySelector("#monkey") as HTMLElement; +const monkeyFastEl = document.querySelector("#monkey .fast") as HTMLElement; ConfigEvent.subscribe((eventKey) => { if (eventKey === "monkey" && TestState.isActive) { if (Config.monkey) { - $("#monkey").removeClass("hidden"); + monkeyEl.classList.remove("hidden"); } else { - $("#monkey").addClass("hidden"); + monkeyEl.classList.add("hidden"); } } }); @@ -30,17 +31,17 @@ const middleKeysState = { left: false, right: false, last: "right" }; // 11 both hands down const elements = { - "00": document.querySelector("#monkey .up"), - "01": document.querySelector("#monkey .right"), - "10": document.querySelector("#monkey .left"), - "11": document.querySelector("#monkey .both"), + "00": monkeyEl.querySelector(".up"), + "01": monkeyEl.querySelector(".right"), + "10": monkeyEl.querySelector(".left"), + "11": monkeyEl.querySelector(".both"), }; const elementsFast = { - "00": document.querySelector("#monkey .fast .up"), - "01": document.querySelector("#monkey .fast .right"), - "10": document.querySelector("#monkey .fast .left"), - "11": document.querySelector("#monkey .fast .both"), + "00": monkeyFastEl.querySelector(".up"), + "01": monkeyFastEl.querySelector(".right"), + "10": monkeyFastEl.querySelector(".left"), + "11": monkeyFastEl.querySelector(".both"), }; function toBit(b: boolean): "1" | "0" { @@ -49,7 +50,7 @@ function toBit(b: boolean): "1" | "0" { function update(): void { if (!Config.monkey) return; - if (!document.querySelector("#monkey")?.classList.contains("hidden")) { + if (!monkeyEl?.classList.contains("hidden")) { (Object.keys(elements) as (keyof typeof elements)[]).forEach((key) => { elements[key]?.classList.add("hidden"); }); @@ -67,13 +68,13 @@ function update(): void { export function updateFastOpacity(num: number): void { if (!Config.monkey) return; const opacity = mapRange(num, 130, 180, 0, 1); - animate("#monkey .fast", { + animate(monkeyFastEl, { opacity: opacity, duration: 1000, }); let animDuration = mapRange(num, 130, 180, 0.25, 0.01); if (animDuration === 0.25) animDuration = 0; - monkey.style.animationDuration = animDuration + "s"; + monkeyEl.style.animationDuration = animDuration + "s"; } export function type(event: JQuery.KeyDownEvent | KeyboardEvent): void { @@ -143,20 +144,21 @@ export function stop(event: JQuery.KeyUpEvent | KeyboardEvent): void { export function show(): void { if (!Config.monkey) return; - $("#monkey").removeClass("hidden"); - animate("#monkey", { + monkeyEl.classList.remove("hidden"); + animate(monkeyEl, { opacity: [0, 1], duration: 125, }); } export function hide(): void { - animate("#monkey", { + animate(monkeyEl, { opacity: [1, 0], duration: 125, onComplete: () => { - $("#monkey").addClass("hidden").css({ animationDuration: "0s" }); - $("#monkey .fast").css("opacity", 0); + monkeyEl.classList.add("hidden"); + monkeyEl.style.animationDuration = "0s"; + monkeyFastEl.style.opacity = "0"; }, }); } From 78ad855a628ab387d2cbe6802280051decaa35ff Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 14:49:59 +0100 Subject: [PATCH 06/27] comment --- frontend/src/ts/input/handlers/before-insert-text.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 34e2269ed095..4ebb7a7c2a97 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -67,9 +67,6 @@ export function onBeforeInsertText(data: string): boolean { // 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; @@ -82,6 +79,8 @@ export function onBeforeInsertText(data: string): boolean { (shouldInsertSpaceAsCharacter === true || !dataIsSpace) && Config.mode !== "zen" ) { + // make sure to only check this when really necessary + // because this check is expensive (causes layout reflows) const topAfterAppend = TestUI.getActiveWordTopAfterAppend(data); const wordJumped = topAfterAppend > TestUI.activeWordTop; if (wordJumped) { From 96d6be0363c2d0c34c15549466b1809ffd9ae0a7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 15:02:15 +0100 Subject: [PATCH 07/27] spark position --- frontend/src/ts/elements/monkey-power.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/elements/monkey-power.ts b/frontend/src/ts/elements/monkey-power.ts index f86f7dbe2d1a..270675037ff4 100644 --- a/frontend/src/ts/elements/monkey-power.ts +++ b/frontend/src/ts/elements/monkey-power.ts @@ -223,7 +223,7 @@ export async function addPower(good = true, extra = false): Promise { const offset = ctx.caret?.getBoundingClientRect(); const coords = [ offset?.left ?? 0, - (offset?.top ?? 0) + (ctx.caret?.offsetHeight ?? 0), + (offset?.top ?? 0) + (ctx.caret?.offsetHeight ?? 0) / 2, ]; for ( From a3fd0d4f8ed811be553f3078ef1152110bf76d15 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 15:02:30 +0100 Subject: [PATCH 08/27] move power to ui --- frontend/src/ts/input/handlers/insert-text.ts | 2 -- frontend/src/ts/test/test-ui.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/input/handlers/insert-text.ts b/frontend/src/ts/input/handlers/insert-text.ts index b3c1f67ca22c..66509b39f2ac 100644 --- a/frontend/src/ts/input/handlers/insert-text.ts +++ b/frontend/src/ts/input/handlers/insert-text.ts @@ -20,7 +20,6 @@ import { 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"; @@ -163,7 +162,6 @@ export async function onInsertText(options: OnInsertTextParams): Promise { // 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(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index d844b3d6c11d..9094e07ab52a 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -44,6 +44,7 @@ import { getInputElement, isInputElementFocused, } from "../input/input-element"; +import * as MonkeyPower from "../elements/monkey-power"; const debouncedZipfCheck = debounce(250, async () => { const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); @@ -1745,6 +1746,7 @@ export function afterTestTextInput( //nospace cant be handled here becauseword index // is already increased at this point + void MonkeyPower.addPower(correct); setActiveWordTop(); if (!increasedWordIndex) { void updateWordLetters({ From c199d5c5036600d652f38ca772335946df39d2d8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 15:19:39 +0100 Subject: [PATCH 09/27] more guards --- frontend/src/ts/input/handlers/before-delete.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/ts/input/handlers/before-delete.ts b/frontend/src/ts/input/handlers/before-delete.ts index a16d1c0a661d..79fb28d77d50 100644 --- a/frontend/src/ts/input/handlers/before-delete.ts +++ b/frontend/src/ts/input/handlers/before-delete.ts @@ -4,12 +4,23 @@ 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"; +import { isAwaitingNextWord } from "../state"; export function onBeforeDelete(event: InputEvent): void { if (!TestState.isActive) { event.preventDefault(); return; } + if (TestState.testRestarting) { + return; + } + if (isAwaitingNextWord()) { + return; + } + if (TestUI.resultCalculating) { + return; + } + const { inputValue } = getInputElementValue(); const inputIsEmpty = inputValue === ""; From f6e73e22f0995e07519ae886d295eee9d2d75312 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 15:19:53 +0100 Subject: [PATCH 10/27] guard --- frontend/src/ts/input/listeners/composition.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/input/listeners/composition.ts b/frontend/src/ts/input/listeners/composition.ts index d3961e77d508..bf7346c9fa44 100644 --- a/frontend/src/ts/input/listeners/composition.ts +++ b/frontend/src/ts/input/listeners/composition.ts @@ -5,6 +5,7 @@ import * as TestLogic from "../../test/test-logic"; import { setLastInsertCompositionTextData } from "../state"; import * as CompositionDisplay from "../../elements/composition-display"; import { onInsertText } from "../handlers/insert-text"; +import * as TestUI from "../../test/test-ui"; const inputEl = getInputElement(); @@ -35,7 +36,7 @@ inputEl.addEventListener("compositionupdate", (event) => { inputEl.addEventListener("compositionend", async (event) => { console.debug("wordsInput event compositionend", { event, data: event.data }); - if (TestState.testRestarting) return; + if (TestState.testRestarting || TestUI.resultCalculating) return; CompositionState.setComposing(false); CompositionState.setData(""); CompositionDisplay.update(""); From dac89fc8e2f0ac178aa7d70fb9fa5d01c08a21d7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 21:45:47 +0100 Subject: [PATCH 11/27] disable timer debug --- frontend/src/ts/test/test-timer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 8129951ac9f6..30137c1bcaa4 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -54,7 +54,7 @@ let timer: NodeJS.Timeout | null = null; const interval = 1000; let expected = 0; -let timerDebug = true; +let timerDebug = false; export function enableTimerDebug(): void { timerDebug = true; } From e524a51c5a295568341ead7c7aa7878e9ddedfa8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 21:46:42 +0100 Subject: [PATCH 12/27] big brain solution --- .../ts/input/handlers/before-insert-text.ts | 9 ++++- frontend/src/ts/test/test-ui.ts | 38 ++++++++++++------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 4ebb7a7c2a97..5a281026c4b7 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -81,7 +81,14 @@ export function onBeforeInsertText(data: string): boolean { ) { // make sure to only check this when really necessary // because this check is expensive (causes layout reflows) - const topAfterAppend = TestUI.getActiveWordTopAfterAppend(data); + + // if there is pending word data, wwe need to account for that + const pendingWordData = TestUI.pendingWordData.get( + TestState.activeWordIndex, + ); + const topAfterAppend = TestUI.getActiveWordTopWithDifferentData( + (pendingWordData ?? TestInput.input.current) + data, + ); const wordJumped = topAfterAppend > TestUI.activeWordTop; if (wordJumped) { return true; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 9094e07ab52a..ee66659d26ea 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -734,20 +734,26 @@ export function colorful(tc: boolean): void { } } -type UpdateActiveWordLettersParams = { - wordIndex: number; - input: string; - compositionData: string; -}; +// 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 +// make sure the currently typed word will not overflow to the next line +export let pendingWordData: Map = new Map(); export async function updateWordLetters({ wordIndex, input, compositionData, -}: UpdateActiveWordLettersParams): Promise { +}: { + wordIndex: number; + input: string; + compositionData: string; +}): Promise { + pendingWordData.set(wordIndex, input); requestDebouncedAnimationFrame( `test-ui.updateWordLetters.${wordIndex}`, async () => { + pendingWordData.delete(wordIndex); const currentWord = TestWords.words.get(wordIndex); if (!currentWord && Config.mode !== "zen") return; let ret = ""; @@ -1682,21 +1688,25 @@ function updateLiveStatsColor(value: TimerColor): void { } } -export function getActiveWordTopAfterAppend(data: string): number { +export function getActiveWordTopWithDifferentData(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; + const nodes = []; + for (let i = activeWord.children.length; i < data.length; i++) { + const tempLetter = document.createElement("letter"); + const displayData = data[i] === " " ? "_" : data[i]; + tempLetter.textContent = displayData as string; + nodes.push(tempLetter); + } - activeWord.appendChild(tempLetter); + activeWord.append(...nodes); const top = activeWord.offsetTop; - tempLetter.remove(); + for (const node of nodes) { + node.remove(); + } return top; } From c9794b5284b45087bb37b645ca10aa0b49d6770a Mon Sep 17 00:00:00 2001 From: Miodec Date: Thu, 27 Nov 2025 21:57:59 +0100 Subject: [PATCH 13/27] disable expensive jump check when timer is slow --- frontend/src/ts/input/handlers/before-insert-text.ts | 2 ++ frontend/src/ts/test/test-ui.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/input/handlers/before-insert-text.ts b/frontend/src/ts/input/handlers/before-insert-text.ts index 5a281026c4b7..9ca90677ff63 100644 --- a/frontend/src/ts/input/handlers/before-insert-text.ts +++ b/frontend/src/ts/input/handlers/before-insert-text.ts @@ -8,6 +8,7 @@ import { isSpace } from "../../utils/strings"; import { getInputElementValue } from "../input-element"; import { isAwaitingNextWord } from "../state"; import { shouldInsertSpaceCharacter } from "../helpers/validation"; +import * as SlowTimer from "../../states/slow-timer"; /** * Handles logic before inserting text into the input element. @@ -72,6 +73,7 @@ export function onBeforeInsertText(data: string): boolean { TestInput.input.current.length >= TestWords.words.getCurrent().length; if ( + !SlowTimer.get() && // don't do this check if slow timer is active dataIsNotFalsy && !Config.blindMode && !Config.hideExtraLetters && diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index ee66659d26ea..9ce2c5d5886e 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -45,6 +45,7 @@ import { isInputElementFocused, } from "../input/input-element"; import * as MonkeyPower from "../elements/monkey-power"; +import * as SlowTimer from "../states/slow-timer"; const debouncedZipfCheck = debounce(250, async () => { const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language); @@ -904,9 +905,11 @@ export async function updateWordLetters({ if (Config.tapeMode !== "off") { void scrollTape(); } - if (Config.mode === "zen") { + if (Config.mode === "zen" || SlowTimer.get()) { // because we block word jumps in before-insert-text // this check only needs to happen in zen mode + // unless slow timer is on, then it needs to happen + // because the word jump check is disabled if (!Config.showAllLines) { const wordTopAfterUpdate = wordAtIndex.offsetTop; if (wordTopAfterUpdate > activeWordTop) { From 8bb9bb7273679595593b91611de39eec8eaa4ce6 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 09:34:15 +0100 Subject: [PATCH 14/27] remove debug code --- frontend/src/ts/test/test-logic.ts | 1 - frontend/src/ts/test/test-timer.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 7d0f12b03be0..832df666b49d 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -929,7 +929,6 @@ export async function finish(difficultyFailed = false): Promise { TestUI.setResultCalculating(true); const now = performance.now(); TestStats.setEnd(now); - console.log(`Test time timer based: `, TestTimer.getTimerTime() / 1000); if (TestState.isRepeated && Config.mode === "quote") { TestState.setRepeated(false); diff --git a/frontend/src/ts/test/test-timer.ts b/frontend/src/ts/test/test-timer.ts index 30137c1bcaa4..fed9d38db37f 100644 --- a/frontend/src/ts/test/test-timer.ts +++ b/frontend/src/ts/test/test-timer.ts @@ -38,10 +38,6 @@ const newTimer = createTimer({ }, }); -export function getTimerTime(): number { - return newTimer.currentTime; -} - type TimerStats = { dateNow: number; now: number; From a2813d8e5ba81aca865eacd7009fcd700c653d7d Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 09:43:18 +0100 Subject: [PATCH 15/27] unnecessary sleep --- frontend/src/ts/test/test-logic.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 832df666b49d..84ff52a63b51 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -934,7 +934,6 @@ export async function finish(difficultyFailed = false): Promise { TestState.setRepeated(false); } - await Misc.sleep(1); //this is needed to make sure the last keypress is registered if (TestInput.input.current.length !== 0) { TestInput.input.pushHistory(); TestInput.corrected.pushHistory(); From b4b247cf92eb5922303d02da1f1f63e2d4ede280 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 09:43:59 +0100 Subject: [PATCH 16/27] comment --- frontend/src/ts/test/test-logic.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 84ff52a63b51..115a3f6b70dc 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -934,6 +934,8 @@ export async function finish(difficultyFailed = false): Promise { TestState.setRepeated(false); } + // in case the tests ends with a keypress (not a word submission) + // we need to push the current input to history if (TestInput.input.current.length !== 0) { TestInput.input.pushHistory(); TestInput.corrected.pushHistory(); From c2ad456dd7b9a45384cd246e51a35d28bc2b8df0 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 10:28:52 +0100 Subject: [PATCH 17/27] hide replay stuff when updating result page, not starting test --- frontend/src/ts/test/replay.ts | 5 ----- frontend/src/ts/test/result.ts | 3 +++ frontend/src/ts/test/test-ui.ts | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/src/ts/test/replay.ts b/frontend/src/ts/test/replay.ts index 126155108f17..4aa3cc104701 100644 --- a/frontend/src/ts/test/replay.ts +++ b/frontend/src/ts/test/replay.ts @@ -219,11 +219,6 @@ function toggleReplayDisplay(): void { } function startReplayRecording(): void { - if (!$("#resultReplay").stop(true, true).hasClass("hidden")) { - //hide replay display if user left it open - toggleReplayDisplay(); - } - $("#replayStats").text(""); replayData = []; replayStartTime = performance.now(); replayRecording = true; diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 0ff1f752ddb6..114ba26b6ee9 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -984,6 +984,9 @@ export async function update( hideCrown(); $("#resultWordsHistory .words").empty(); $("#result #resultWordsHistory").addClass("hidden"); + $("#result #replayStats").text(""); + $("#result #resultReplay").addClass("hidden"); + $("#result #replayWords").empty(); $("#retrySavingResultButton").addClass("hidden"); $(".pageTest #result #rateQuoteButton .icon") .removeClass("fas") diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 9ce2c5d5886e..71b277b5aecd 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -242,6 +242,7 @@ export function updateActiveElement( newActiveWord.classList.remove("typed"); activeWordTop = newActiveWord.offsetTop; + console.log("activewordtopupdated"); updateWordsInputPosition(); @@ -1760,7 +1761,7 @@ export function afterTestTextInput( // is already increased at this point void MonkeyPower.addPower(correct); - setActiveWordTop(); + if (!increasedWordIndex) { void updateWordLetters({ input: inputOverride ?? TestInput.input.current, @@ -1867,7 +1868,6 @@ export async function afterTestWordChange( } export function afterTestStart(): void { - setActiveWordTop(); Focus.set(true); Monkey.show(); TimerProgress.show(); From abd679e7c131bdc9252a2aae3233e4b73f6ec53a Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 14:46:40 +0100 Subject: [PATCH 18/27] allow strings --- frontend/src/ts/utils/misc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 9d4c26c8bfee..f134d4208d50 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -442,7 +442,7 @@ export type JQueryEasing = | "easeInOutBounce"; export async function promiseAnimate( - el: HTMLElement, + el: HTMLElement | string, options: AnimationParams, ): Promise { return new Promise((resolve) => { From 613efd932bbc25c9be9f9829d2535dd14d6e9208 Mon Sep 17 00:00:00 2001 From: Miodec Date: Fri, 28 Nov 2025 14:54:16 +0100 Subject: [PATCH 19/27] restructure result showing show loading icon if it takes longer than 500ms to calculate the result --- frontend/src/html/pages/test.html | 3 ++ frontend/src/styles/test.scss | 25 +++++++++++ frontend/src/ts/test/result.ts | 69 ++++++++++++++---------------- frontend/src/ts/test/test-logic.ts | 11 +++++ 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index 777d1f7dadc7..95ad7beed8a8 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -192,6 +192,9 @@ +