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