import * as TestWords from "./test-words"; import { Config } from "../config/store"; import * as DB from "../db"; import { getActiveTagsPB } from "../collections/tags"; import * as Misc from "../utils/misc"; import { configEvent } from "../events/config"; import { getActiveFunboxes } from "./funbox/list"; import { Caret } from "../elements/caret"; import { ElementWithUtils } from "../utils/dom"; import { getUserAverage10Once, getUserDailyBestOnce, } from "../collections/results"; import { isDirectionReversed, isLanguageRightToLeft, getActiveWordIndex, getCurrentQuote, getResultVisible, isPaceRepeat, isTestActive, setPaceCaretWpm, } from "../states/test"; type Settings = { wpm: number; cps: number; spc: number; correction: number; currentWordIndex: number; currentLetterIndex: number; wordsStatus: Record; timeout: NodeJS.Timeout | null; }; let startTimestamp = 0; let settings: Settings | null = null; // #paceCaret is rendered by TestPage, set via initElement() on every mount export let caret: Caret | undefined; export function initElement( refs: { caret: ElementWithUtils; words: ElementWithUtils; wordsWrapper: ElementWithUtils; }, signal: AbortSignal, ): void { const created = new Caret(refs.caret, Config.paceCaretStyle, refs); caret = created; signal.addEventListener("abort", () => { // a remount may have created a new caret already if (caret === created) caret = undefined; }); } let lastTestWpm = 0; export function setLastTestWpm(wpm: number): void { if (!isPaceRepeat() || (isPaceRepeat() && wpm > lastTestWpm)) { lastTestWpm = wpm; } } export function resetCaretPosition(): void { if (Config.paceCaret === "off" && !isPaceRepeat()) return; if (Config.mode === "zen") return; caret?.hide(); caret?.stopAllAnimations(); caret?.clearMargins(); caret?.goTo({ wordIndex: 0, letterIndex: 0, isLanguageRightToLeft: isLanguageRightToLeft(), isDirectionReversed: isDirectionReversed(), animate: false, }); } export async function init(): Promise { caret?.hide(); const mode2 = Misc.getMode2(Config, getCurrentQuote()); let wpm = 0; if (Config.paceCaret === "pb") { wpm = DB.getLocalPB( Config.mode, mode2, Config.punctuation, Config.numbers, Config.language, Config.difficulty, Config.lazyMode, getActiveFunboxes(), )?.wpm ?? 0; } else if (Config.paceCaret === "tagPb") { wpm = getActiveTagsPB( Config.mode, mode2, Config.punctuation, Config.numbers, Config.language, Config.difficulty, Config.lazyMode, ); } else if (Config.paceCaret === "average") { wpm = Math.round((await getUserAverage10Once({ ...Config, mode2 })).wpm); } else if (Config.paceCaret === "daily") { wpm = Math.round((await getUserDailyBestOnce({ ...Config, mode2 })).wpm); } else if (Config.paceCaret === "custom") { wpm = Config.paceCaretCustomSpeed; } else if (Config.paceCaret === "last" || isPaceRepeat()) { wpm = lastTestWpm; } if (wpm === undefined || wpm < 1 || Number.isNaN(wpm)) { settings = null; setPaceCaretWpm(undefined); return; } const characters = wpm * 5; const cps = characters / 60; //characters per step const spc = 60 / characters; //seconds per character settings = { wpm: wpm, cps: cps, spc: spc, correction: 0, currentWordIndex: 0, currentLetterIndex: 0, wordsStatus: {}, timeout: null, }; setPaceCaretWpm(wpm); } export async function update(expectedStepEnd: number): Promise { const currentSettings = settings; if (currentSettings === null || !isTestActive() || getResultVisible()) { return; } if (caret?.isHidden()) { caret?.show(); } incrementLetterIndex(); try { const now = performance.now(); const absoluteStepEnd = startTimestamp + expectedStepEnd; const duration = absoluteStepEnd - now; caret?.goTo({ wordIndex: currentSettings.currentWordIndex, letterIndex: currentSettings.currentLetterIndex, isLanguageRightToLeft: isLanguageRightToLeft(), isDirectionReversed: isDirectionReversed(), animate: true, animationOptions: { duration, easing: "linear", }, }); currentSettings.timeout = setTimeout( () => { if (settings !== currentSettings) return; update(expectedStepEnd + (currentSettings.spc ?? 0) * 1000).catch( () => { if (settings === currentSettings) settings = null; }, ); }, Math.max(0, duration), ); } catch (e) { console.error(e); caret?.hide(); return; } } export function reset(): void { if (settings?.timeout !== null && settings?.timeout !== undefined) { clearTimeout(settings.timeout); } settings = null; startTimestamp = 0; } function incrementLetterIndex(): void { if (settings === null) return; try { if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined TestWords.words.get(settings.currentWordIndex)!.text.length ) { //go to the next word settings.currentLetterIndex = -1; settings.currentWordIndex++; } settings.currentLetterIndex++; if (!Config.blindMode) { if (settings.correction < 0) { while (settings.correction < 0) { settings.currentLetterIndex--; if (settings.currentLetterIndex <= -1) { //go to the previous word settings.currentLetterIndex = // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined TestWords.words.get(settings.currentWordIndex - 1)!.text.length; settings.currentWordIndex--; } settings.correction++; } } else if (settings.correction > 0) { while (settings.correction > 0) { settings.currentLetterIndex++; if ( settings.currentLetterIndex >= // oxlint-disable-next-line typescript/no-non-null-assertion let it throw if undefined TestWords.words.get(settings.currentWordIndex)!.text.length + 1 ) { //go to the next word settings.currentLetterIndex = 0; settings.currentWordIndex++; } settings.correction--; } } } } catch (e) { //out of words settings = null; console.log("pace caret out of words"); caret?.hide(); return; } } export function handleSpace(correct: boolean, currentWord: string): void { if (correct) { if ( settings?.wordsStatus[getActiveWordIndex()] === true && !Config.blindMode ) { settings.wordsStatus[getActiveWordIndex()] = undefined; settings.correction -= currentWord.length; } } else { if ( settings !== null && settings.wordsStatus[getActiveWordIndex()] === undefined && !Config.blindMode ) { settings.wordsStatus[getActiveWordIndex()] = true; settings.correction += currentWord.length; } } } export function start(): void { const now = performance.now(); startTimestamp = now; void update((settings?.spc ?? 0) * 1000); } configEvent.subscribe(({ key }) => { if (key === "paceCaret") void init(); if (key === "paceCaretStyle") { caret?.setStyle(Config.paceCaretStyle); } });