Fork of atp.tools as a universal profile for people on the ATmosphere

animated thingies to the secret thingy

+347 -115
+23 -8
src/components/rnfgrertt/calculateStats.ts
··· 21 21 } 22 22 } 23 23 24 + let errorsMade = errors.length; 25 + let correctKeystrokes = userInput.length - errorsMade; 26 + 24 27 // Calculate WPM (only correct words) 25 - const wpm = (correctChars / 5) * (60 / timeElapsed); 28 + const wpm = (correctKeystrokes / 5) * (60 / timeElapsed); 26 29 27 30 // Calculate Raw WPM (including incorrect words) 28 31 const rawWpm = (userInput.length / 5) * (60 / timeElapsed); 29 32 30 33 // Calculate accuracy 31 - const accuracy = (correctChars / totalKeystrokes) * 100; 34 + const accuracy = (correctKeystrokes / totalKeystrokes) * 100; 32 35 33 36 // Calculate character ratio 34 37 const incorrectChars = totalKeystrokes - correctChars; ··· 38 41 // Calculate consistency using coefficient of variation of raw WPM 39 42 const rawWpmValues = wpmData.map((point) => point.wpm); 40 43 const mean = rawWpmValues.reduce((a, b) => a + b, 0) / rawWpmValues.length; 41 - const variance = 42 - rawWpmValues.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / 43 - rawWpmValues.length; 44 - const stdDev = Math.sqrt(variance); 45 - const cv = (stdDev / mean) * 100; 46 - const consistency = Math.max(0, Math.min(100, 100 - cv)); 44 + 45 + // Calculate weighted standard deviation 46 + const weightedVariance = 47 + rawWpmValues.reduce((acc, wpm) => { 48 + const diff = wpm - mean; 49 + // Apply smaller weight to variations at higher speeds 50 + const weight = Math.max(0.5, 100 / mean); 51 + return acc + diff * diff * weight; 52 + }, 0) / rawWpmValues.length; 53 + 54 + const weightedStdDev = Math.sqrt(weightedVariance); 55 + 56 + // Adjust consistency calculation for higher WPM 57 + const baseConsistency = Math.max(0, 100 - (weightedStdDev / mean) * 100); 58 + const consistency = Math.min( 59 + 100, 60 + baseConsistency * (1 + Math.log10(mean / 100)), 61 + ); 47 62 48 63 return { 49 64 wpm: wpm,
+44
src/components/rnfgrertt/helpModal.tsx
··· 1 + import { 2 + Dialog, 3 + DialogContent, 4 + DialogHeader, 5 + DialogTitle, 6 + } from "@/components/ui/dialog"; 7 + import { ArrowUp, ChevronUp, Command } from "lucide-react"; 8 + 9 + export const HelpModal = ({ 10 + isOpen, 11 + onClose, 12 + }: { 13 + isOpen: boolean; 14 + onClose: () => void; 15 + }) => { 16 + return ( 17 + <Dialog open={isOpen} onOpenChange={onClose}> 18 + <DialogContent className="sm:max-w-md"> 19 + <DialogHeader> 20 + <DialogTitle>type@tools help</DialogTitle> 21 + </DialogHeader> 22 + <div className="space-y-2"> 23 + <div className="flex justify-between"> 24 + <span>reset test</span> 25 + <kbd className="px-2 py-1 bg-muted rounded"> 26 + <ArrowUp className="inline mb-0.5" height={16} width={16} /> + esc 27 + </kbd> 28 + </div> 29 + <div className="flex justify-between"> 30 + <span>rotate cursor style</span> 31 + <kbd className="px-2 py-1 bg-muted rounded">-</kbd> 32 + </div> 33 + <div className="flex justify-between"> 34 + <span>help menu (this page)</span> 35 + <kbd className="px-2 py-1 bg-gray-100 dark:bg-muted rounded"> 36 + <ChevronUp className="inline mb-2" height={16} width={16} /> /{" "} 37 + <Command className="inline mb-0.5" height={16} width={16} /> + h 38 + </kbd> 39 + </div> 40 + </div> 41 + </DialogContent> 42 + </Dialog> 43 + ); 44 + };
+2 -2
src/components/rnfgrertt/hooks/useWpmTracker.tsx
··· 15 15 16 16 useEffect(() => { 17 17 userInputRef.current = userInput; 18 - if (userInputRef.current.length >= 5 && wpmData.length < 1) { 18 + if (userInputRef.current.length >= 10 && wpmData.length < 1) { 19 19 updateWPMData(); 20 20 } 21 21 }, [userInput]); ··· 51 51 calculateMetrics(now); 52 52 53 53 // Calculate errors per second 54 - const timeDiff = (now - (lastUpdateRef.current || now)) / 250; 54 + const timeDiff = (now - (lastUpdateRef.current || now)) / UPDATE_INTERVAL; 55 55 const errorDelta = currentErrors - prevErrorsRef.current; 56 56 const errorsPerSecond = timeDiff > 0 ? errorDelta / timeDiff : 0; 57 57
+4 -4
src/components/rnfgrertt/resultsView.tsx
··· 60 60 </h2> 61 61 <div ref={resultsRef} className="bg-card text-base p-4"> 62 62 <div className="flex flex-col lg:flex-row"> 63 - <div className=" flex flex-row lg:flex-col justify-start lg:justify-center"> 63 + <div className=" flex flex-row lg:flex-col justify-start lg:justify-around"> 64 64 <StatBox label="wpm" value={stats.wpm} /> 65 65 <StatBox label="accuracy" value={stats.accuracy} following="%" /> 66 66 </div> ··· 144 144 <div className="rounded text-left mx-4"> 145 145 <div className=""> 146 146 <div className="text-muted-foreground">{label}</div> 147 - <div className="text-6xl"> 147 + <div className="text-7xl"> 148 148 {typeof value === "number" ? value.toFixed(0) : value} 149 149 {following} 150 150 </div> ··· 203 203 dataKey="time" 204 204 type="number" 205 205 label={{ value: "Time (seconds)", position: "bottom" }} 206 - domain={[0, "max"]} 206 + domain={[1, "max"]} 207 207 tickCount={Math.max(Math.ceil(wpmData.length / 8), 10)} 208 208 /> 209 209 <YAxis ··· 262 262 <div className="bg-muted p-2 border rounded shadow"> 263 263 <p className="text-sm">time: {data.time}s</p> 264 264 <p className="text-sm text-blue-500">wpm: {data.wpm}</p> 265 - <p className="text-sm text-blue-500">raw: {data.wpm}</p> 265 + <p className="text-sm text-blue-500">raw: {data.rawWpm}</p> 266 266 <p className="text-sm text-red-500">errors: {data.errorsPerSecond}</p> 267 267 </div> 268 268 );
+8 -4
src/components/rnfgrertt/textGenerator.ts
··· 11 11 ); 12 12 }; 13 13 14 - export const getRandomText = (length: "short" | "med" | "long") => { 14 + export const getRandomText = (length: "short" | "med" | "long" | "xl") => { 15 15 let minLen = 0; 16 16 let maxLen = 0; 17 17 18 18 switch (length) { 19 19 case "short": 20 - minLen = 50; 21 - maxLen = 100; 20 + minLen = 0; 21 + maxLen = 75; 22 22 break; 23 23 case "med": 24 - minLen = 100; 24 + minLen = 75; 25 25 maxLen = 200; 26 26 break; 27 27 case "long": 28 28 minLen = 200; 29 29 maxLen = 400; 30 + break; 31 + case "xl": 32 + minLen = 400; 33 + maxLen = 999; 30 34 break; 31 35 } 32 36
+2
src/components/rnfgrertt/types.ts
··· 29 29 }; 30 30 31 31 export type TimerOption = 15 | 30 | 60 | 120; 32 + 33 + export type CursorStyle = "block" | "line" | "underline";
+161 -81
src/components/rnfgrertt/typingArea.tsx
··· 1 - import { useRef, useEffect } from "preact/hooks"; 1 + import { useRef, useEffect, useState } from "preact/hooks"; 2 2 import { TIMER_OPTIONS } from "./constants"; 3 - import { TimerOption } from "./types"; 4 - import { ComponentChild, VNode } from "preact"; 3 + import { CursorStyle, TimerOption } from "./types"; 4 + import { KbdKey } from "../ui/kbdKey"; 5 + import { isOnMac } from "../smartSearchBar"; 5 6 6 7 export const TypingArea = ({ 7 8 userInput, ··· 16 17 onSelectQuoteLen, 17 18 randomTextLen, 18 19 onSelectRandomTextLen, 20 + cursorStyle = "block", 19 21 }: { 20 22 userInput: string; 21 23 handleInput: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; ··· 25 27 selectedTime: TimerOption | null; 26 28 onSelectTime: (time: TimerOption) => void; 27 29 timeRemaining: number | null; 28 - selectedQuoteLen: "short" | "med" | "long"; 29 - onSelectQuoteLen: (len: "short" | "med" | "long") => void; 30 + selectedQuoteLen: "short" | "med" | "long" | "xl"; 31 + onSelectQuoteLen: (len: "short" | "med" | "long" | "xl") => void; 30 32 randomTextLen: TimerOption | null; 31 33 onSelectRandomTextLen: (time: TimerOption) => void; 34 + cursorStyle: CursorStyle; 32 35 }) => { 33 36 const textareaRef = useRef<HTMLTextAreaElement>(null); 34 37 35 38 useEffect(() => { 36 39 textareaRef.current?.focus(); 37 - }, []); 40 + }, [sampleText]); 38 41 39 42 return ( 40 - <div className="m-auto flex-1 relative rounded-lg max-w-2xl"> 41 - <div className="flex gap-4 justify-between align-middle h-full"> 42 - <div className="flex gap-4"> 43 - <SelectorButtons 44 - options={["random", "quotes", "time"] as const} 45 - selected={selectedMode} 46 - onSelect={(option) => 47 - onSelectMode(option as "random" | "quotes" | "time") 48 - } 49 - /> 50 - {selectedMode === "time" ? ( 43 + <div className="flex flex-col flex-auto"> 44 + <div className="m-auto flex-1 relative rounded-lg max-w-2xl w-full flex flex-col justify-center"> 45 + <div className="flex gap-4 justify-between align-middle h-min"> 46 + <div className="flex gap-4"> 51 47 <SelectorButtons 52 - options={[...TIMER_OPTIONS]} 53 - selected={selectedTime} 54 - onSelect={(option) => onSelectTime(option as TimerOption)} 55 - suffix="s" 56 - /> 57 - ) : selectedMode === "quotes" ? ( 58 - <SelectorButtons 59 - options={["short", "med", "long"] as const} 60 - selected={selectedQuoteLen} 48 + options={["random", "quotes", "time"] as const} 49 + selected={selectedMode} 61 50 onSelect={(option) => 62 - onSelectQuoteLen(option as "short" | "med" | "long") 51 + onSelectMode(option as "random" | "quotes" | "time") 63 52 } 64 53 /> 65 - ) : ( 66 - <SelectorButtons 67 - options={[...TIMER_OPTIONS]} 68 - selected={randomTextLen} 69 - onSelect={(option) => 70 - onSelectRandomTextLen(option as TimerOption) 71 - } 72 - /> 73 - )} 54 + <div className="place-self-center w-8 border-t-2 h-[40%] -mr-4" /> 55 + {selectedMode === "time" ? ( 56 + <SelectorButtons 57 + options={[...TIMER_OPTIONS]} 58 + selected={selectedTime} 59 + onSelect={(option) => onSelectTime(option as TimerOption)} 60 + suffix="s" 61 + /> 62 + ) : selectedMode === "quotes" ? ( 63 + <SelectorButtons 64 + options={["short", "med", "long"] as const} 65 + selected={selectedQuoteLen} 66 + onSelect={(option) => 67 + onSelectQuoteLen(option as "short" | "med" | "long" | "xl") 68 + } 69 + /> 70 + ) : ( 71 + <SelectorButtons 72 + options={[...TIMER_OPTIONS]} 73 + selected={randomTextLen} 74 + onSelect={(option) => 75 + onSelectRandomTextLen(option as TimerOption) 76 + } 77 + /> 78 + )} 79 + </div> 80 + <div className="my-auto pb-4">{timeRemaining?.toFixed(0)}</div> 74 81 </div> 75 - <div className="my-auto pb-4">{timeRemaining?.toFixed(0)}</div> 82 + <div className="max-h-[70vh] overflow-y-auto bg-muted p-4 rounded-lg"> 83 + <TextDisplay 84 + userInput={userInput} 85 + sampleText={sampleText} 86 + cursorStyle={cursorStyle} 87 + /> 88 + <textarea 89 + ref={textareaRef} 90 + className="absolute top-12 left-0 w-full h-full p-4 resize-none bg-transparent text-transparent caret-transparent outline-none scrollbar-hide" 91 + value={userInput} 92 + onChange={handleInput} 93 + spellcheck={false} 94 + autoCorrect="off" 95 + autocapitalize="off" 96 + /> 97 + </div> 76 98 </div> 77 - <div className="max-h-[70vh] overflow-y-auto bg-muted p-4 rounded-lg"> 78 - <TextDisplay userInput={userInput} sampleText={sampleText} /> 79 - <textarea 80 - ref={textareaRef} 81 - className="absolute top-12 left-0 w-full h-full p-4 resize-none bg-transparent text-transparent caret-transparent outline-none scrollbar-hide" 82 - value={userInput} 83 - onChange={handleInput} 84 - spellcheck={false} 85 - autoCorrect="off" 86 - autocapitalize="off" 87 - /> 99 + <div className="text-muted-foreground text-sm flex align-middle gap-1 ml-4"> 100 + <KbdKey keys={[isOnMac() ? "cmd" : "ctrl", "h"]} /> 101 + <div className="mt-0.5">for help</div> 88 102 </div> 89 103 </div> 90 104 ); ··· 93 107 const TextDisplay = ({ 94 108 userInput, 95 109 sampleText, 110 + cursorStyle = "block", 96 111 }: { 97 112 userInput: string; 98 113 sampleText: string; 114 + cursorStyle?: CursorStyle; 99 115 }) => { 100 116 const containerRef = useRef<HTMLDivElement>(null); 117 + const cursorRef = useRef<HTMLDivElement>(null); 118 + const [cursorPosition, setCursorPosition] = useState({ left: 0, top: 0 }); 101 119 const LINE_HEIGHT = 30; 102 120 const VISIBLE_LINES = 3; 103 121 122 + const getCursorStyles = (currentSpan: HTMLElement) => { 123 + const spanRect = currentSpan.getBoundingClientRect(); 124 + 125 + switch (cursorStyle) { 126 + case "block": 127 + return { 128 + width: `${spanRect.width + 0.5}px`, 129 + height: `${spanRect.height}px`, 130 + backgroundColor: "hsl(var(--card))", 131 + mixBlendMode: "difference", 132 + }; 133 + case "line": 134 + return { 135 + width: "2px", 136 + height: `${spanRect.height}px`, 137 + backgroundColor: "hsl(var(--card))", 138 + mixBlendMode: "difference", 139 + }; 140 + case "underline": 141 + return { 142 + width: `${spanRect.width}px`, 143 + height: "2px", 144 + backgroundColor: "hsl(var(--card))", // Use white for inversion 145 + mixBlendMode: "difference", 146 + top: `${spanRect.height - 2}px`, 147 + }; 148 + default: 149 + return {}; 150 + } 151 + }; 152 + 153 + useEffect(() => { 154 + const cursor = cursorRef.current; 155 + if (!cursor) return; 156 + 157 + console.log("resetting anim"); 158 + 159 + // Reset animation 160 + cursor.style.animation = "none"; 161 + cursor.offsetHeight; 162 + cursor.style.animation = 163 + cursorStyle === "block" 164 + ? "blink 1s ease-in-out infinite" 165 + : "blink 1s ease-in-out infinite"; 166 + }, [cursorPosition, cursorStyle]); 167 + 168 + // Update cursor position when input changes 104 169 useEffect(() => { 105 170 const container = containerRef.current; 106 - if (!container) return; 171 + const cursor = cursorRef.current; 172 + if (!container || !cursor) return; 107 173 108 174 const currentCharIndex = userInput.length; 109 175 const spans = container.children; 110 - if (currentCharIndex >= spans.length) return; 176 + 177 + // Skip the cursor element itself when getting spans 178 + const textSpans = Array.from(spans).filter((span) => span !== cursor); 179 + 180 + if (currentCharIndex >= textSpans.length) return; 181 + 182 + const currentSpan = textSpans[currentCharIndex] as HTMLElement; 183 + const containerRect = container.getBoundingClientRect(); 184 + const spanRect = currentSpan.getBoundingClientRect(); 185 + 186 + // Position cursor relative to container 187 + const relativeLeft = spanRect.left - containerRect.left; 188 + const relativeTop = spanRect.top - containerRect.top; 189 + 190 + // force cursor rerender 191 + setCursorPosition({ left: relativeLeft, top: relativeTop }); 192 + 193 + // Apply cursor styles 194 + const cursorStyles = getCursorStyles(currentSpan); 195 + Object.assign(cursor.style, { 196 + left: `${relativeLeft}px`, 197 + top: `${currentSpan.offsetTop}px`, 198 + ...cursorStyles, 199 + }); 111 200 112 - const currentSpan = spans[currentCharIndex] as HTMLElement; 201 + // Handle scrolling 113 202 const spanOffsetTop = currentSpan.offsetTop; 114 203 const maxScrollTop = container.scrollHeight - container.clientHeight; 115 - const desiredScrollTop = Math.min( 116 - Math.max(0, spanOffsetTop - LINE_HEIGHT), 117 - maxScrollTop, 118 - ); 204 + const desiredScrollTop = Math.min(Math.max(0, spanOffsetTop), maxScrollTop); 119 205 120 206 container.scrollTop = desiredScrollTop; 121 - }, [userInput.length, sampleText]); 207 + }, [userInput.length, sampleText, cursorStyle]); 122 208 123 209 return ( 124 210 <div ··· 130 216 style={{ height: `${LINE_HEIGHT * VISIBLE_LINES}px` }} 131 217 className="whitespace-pre-wrap break-words text-xl leading-[30px] absolute w-full transition-transform duration-100 overflow-y-scroll scrollbar-hide" 132 218 > 133 - {sampleText 134 - .split("") 135 - .map( 136 - ( 137 - char: 138 - | string 139 - | number 140 - | bigint 141 - | boolean 142 - | object 143 - | ComponentChild[] 144 - | VNode<any> 145 - | null 146 - | undefined, 147 - i: number, 148 - ) => ( 149 - <span 150 - key={i} 151 - className={` 219 + {/* Cursor */} 220 + <div 221 + ref={cursorRef} 222 + className="absolute w-[2px] bg-primary transition-all duration-100 animate-blink" 223 + style={{ 224 + left: 0, 225 + top: 0, 226 + height: "24px", 227 + }} 228 + /> 229 + 230 + {sampleText.split("").map((char, i) => ( 231 + <span 232 + key={i} 233 + className={` 152 234 ${ 153 235 i < userInput.length 154 236 ? userInput[i] === char ··· 156 238 : "text-red-500 underline" 157 239 : "" 158 240 } 159 - ${i === userInput.length ? "bg-muted animate-blink" : ""} 160 241 `} 161 - > 162 - {char} 163 - </span> 164 - ), 165 - )} 242 + > 243 + {char} 244 + </span> 245 + ))} 166 246 </div> 167 247 </div> 168 248 );
+1 -1
src/components/smartSearchBar.tsx
··· 17 17 // return "unknown"; 18 18 // } 19 19 20 - function isOnMac() { 20 + export function isOnMac() { 21 21 return navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; 22 22 } 23 23
-2
src/components/ui/kbdKey.tsx
··· 44 44 // Check if all keys are pressed 45 45 const allKeysPressed = keyStates.every((state) => state === true); 46 46 47 - console.log(keyStates); 48 - 49 47 return ( 50 48 <div 51 49 ref={ref}
+51
src/hooks/useStoredState.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import type { Dispatch, StateUpdater } from "preact/hooks"; 3 + 4 + /** 5 + * Sets a value in localStorage with JSON stringification 6 + */ 7 + export function setToLocalStorage<T>(key: string, value: T): void { 8 + try { 9 + localStorage.setItem(key, JSON.stringify(value)); 10 + } catch (error) { 11 + console.error(`Error saving to localStorage (${key}):`, error); 12 + } 13 + } 14 + 15 + /** 16 + * Gets a value from localStorage with JSON parsing 17 + */ 18 + export function getFromLocalStorage<T>(key: string, defaultValue: T): T { 19 + try { 20 + const item = localStorage.getItem(key); 21 + 22 + if (item === null) { 23 + setToLocalStorage(key, defaultValue); 24 + return defaultValue; 25 + } 26 + 27 + return JSON.parse(item) as T; 28 + } catch (error) { 29 + console.error(`Error reading from localStorage (${key}):`, error); 30 + return defaultValue; 31 + } 32 + } 33 + 34 + /** 35 + * Custom hook that syncs state with localStorage 36 + */ 37 + export function useStoredState<T>( 38 + key: string, 39 + defaultValue: T, 40 + ): [T, Dispatch<StateUpdater<T>>] { 41 + const [value, setValue] = useState<T>(() => 42 + getFromLocalStorage(key, defaultValue), 43 + ); 44 + 45 + useEffect(() => { 46 + console.log("useStoredState", key, value); 47 + setToLocalStorage(key, value); 48 + }, [key, value]); 49 + 50 + return [value, setValue]; 51 + }
+2 -2
src/index.css
··· 172 172 animation-delay: -0.1s; 173 173 } 174 174 175 - @keyframes invertBlink { 175 + @keyframes blink { 176 176 0% { 177 177 filter: invert(100%); 178 178 } ··· 191 191 } 192 192 193 193 .animate-blink { 194 - animation: invertBlink 1s infinite; 194 + animation: blink 1s infinite; 195 195 } 196 196 197 197 .scrollbar-hide {
+49 -11
src/routes/rnfgrertt/typing.lazy.tsx
··· 1 1 import { calculateStats } from "@/components/rnfgrertt/calculateStats"; 2 2 import { UPDATE_INTERVAL } from "@/components/rnfgrertt/constants"; 3 + import { HelpModal } from "@/components/rnfgrertt/helpModal"; 3 4 import { useTypingMetricsTracker } from "@/components/rnfgrertt/hooks/useStatsTracker"; 4 5 import { useTypingTest } from "@/components/rnfgrertt/hooks/useTypingTest"; 5 6 import { useWpmTracker } from "@/components/rnfgrertt/hooks/useWpmTracker"; ··· 9 10 getRandomText, 10 11 } from "@/components/rnfgrertt/textGenerator"; 11 12 import { 13 + CursorStyle, 12 14 TextMeta, 13 15 TimerOption, 14 16 TypingStats, 15 17 } from "@/components/rnfgrertt/types"; 16 18 import { TypingArea } from "@/components/rnfgrertt/typingArea"; 19 + import { isOnMac } from "@/components/smartSearchBar"; 20 + import { KbdKey } from "@/components/ui/kbdKey"; 21 + import { useStoredState } from "@/hooks/useStoredState"; 17 22 import { createLazyFileRoute } from "@tanstack/react-router"; 18 23 import { useState, useMemo, useEffect } from "preact/hooks"; 19 24 ··· 21 26 component: TypingTest, 22 27 }); 23 28 24 - const useKeyboardShortcuts = (resetCallback: () => void) => { 29 + const useKeyboardShortcuts = ( 30 + resetCallback: () => void, 31 + cursorStyle: CursorStyle, 32 + toggleCursorStyle: (style: CursorStyle) => void, 33 + toggleHelp: () => void, 34 + ) => { 25 35 useEffect(() => { 26 36 const handleKeyDown = (e: KeyboardEvent) => { 27 - // Alternative: Reset on Escape key 28 - if (e.key === "Escape") { 37 + // Help menu on Ctrl/Cmd + H 38 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "h") { 39 + e.preventDefault(); 40 + toggleHelp(); 41 + } 42 + // Existing shortcuts 43 + if (e.shiftKey && e.key === "Escape") { 29 44 e.preventDefault(); 30 45 resetCallback(); 31 46 } 47 + if (e.key === "-") { 48 + e.preventDefault(); 49 + if (cursorStyle === "block") toggleCursorStyle("line"); 50 + else if (cursorStyle === "line") toggleCursorStyle("underline"); 51 + else toggleCursorStyle("block"); 52 + } 32 53 }; 33 54 34 55 window.addEventListener("keydown", handleKeyDown); 35 56 return () => window.removeEventListener("keydown", handleKeyDown); 36 - }, [resetCallback]); 57 + }, [resetCallback, toggleHelp]); 37 58 }; 38 59 39 60 export interface TestConfig { 40 61 mode: "random" | "quotes" | "time"; 41 62 selectedTimer: TimerOption | null; 42 63 randomLen: TimerOption | null; 43 - quoteLen: "short" | "med" | "long"; 64 + quoteLen: "short" | "med" | "long" | "xl"; 65 + cursorStyle: CursorStyle; 44 66 } 45 67 46 68 // Initial state constants ··· 49 71 selectedTimer: 30, 50 72 randomLen: 30, 51 73 quoteLen: "short", 74 + cursorStyle: "block", 52 75 }; 53 76 54 77 const INITIAL_WORD_COUNT = { ··· 58 81 59 82 function TypingTest() { 60 83 // Group related state 61 - const [config, setConfig] = useState<TestConfig>(DEFAULT_CONFIG); 84 + const [config, setConfig] = useStoredState<TestConfig>( 85 + "atp-tools-typing-test-config", 86 + DEFAULT_CONFIG, 87 + ); 62 88 const [currentTextMeta, setCurrentText] = useState<string | TextMeta>( 63 89 config.mode === "time" 64 90 ? generateWords(INITIAL_WORD_COUNT.time) 65 91 : getRandomText(config.quoteLen), 66 92 ); 67 93 94 + const [showHelp, setShowHelp] = useState(false); 95 + 96 + const toggleHelp = () => setShowHelp((prev) => !prev); 97 + 68 98 // Helper functions 69 99 const getCurrentText = (textMeta: string | TextMeta): string => { 70 100 return typeof textMeta === "object" ? textMeta.text : textMeta; ··· 90 120 setConfig((prev) => ({ ...prev, selectedTimer: timer })); 91 121 }; 92 122 93 - const handleQuoteLenChange = (length: "short" | "med" | "long") => { 123 + const handleQuoteLenChange = (length: "short" | "med" | "long" | "xl") => { 94 124 setConfig((prev) => ({ ...prev, quoteLen: length })); 95 125 }; 96 126 ··· 102 132 typingTest.handleInput((e.target as any)?.value); 103 133 }; 104 134 135 + const setCursorStyle = (style: CursorStyle) => { 136 + setConfig((prev) => ({ ...prev, cursorStyle: style })); 137 + }; 138 + 105 139 const currentText = getCurrentText(currentTextMeta); 106 140 107 141 const typingTest = useTypingTest( ··· 137 171 }, [config.selectedTimer, config.randomLen, config.quoteLen, config.mode]); 138 172 139 173 // Keyboard shortcuts 140 - useKeyboardShortcuts(resetAll); 174 + useKeyboardShortcuts( 175 + resetAll, 176 + config.cursorStyle, 177 + setCursorStyle, 178 + toggleHelp, 179 + ); 141 180 142 181 const metricsHistory = useTypingMetricsTracker( 143 182 typingTest.userInput, ··· 176 215 ).length, // Remove the division by (UPDATE_INTERVAL / 250) 177 216 })); 178 217 }, [wpmData, typingTest.errors, typingTest.startTime]); 179 - 180 - console.log(chartData.filter((f) => f.errorsPerSecond > 0)); 181 - 182 218 return ( 183 219 <main className="h-screen relative max-h-[calc(100vh-5rem)] flex"> 184 220 {typingTest.isFinished ? ( ··· 203 239 onSelectQuoteLen={handleQuoteLenChange} 204 240 randomTextLen={config.randomLen} 205 241 onSelectRandomTextLen={handleRandomLenChange} 242 + cursorStyle={config.cursorStyle} 206 243 /> 207 244 )} 245 + <HelpModal isOpen={showHelp} onClose={() => setShowHelp(false)} /> 208 246 </main> 209 247 ); 210 248 }