- onSelectQuoteLen(option as "short" | "med" | "long")
- }
- />
- ) : (
+
+
+
+
- onSelectRandomTextLen(option as TimerOption)
+ onSelectMode(option as "random" | "quotes" | "time")
}
/>
- )}
+
+ {selectedMode === "time" ? (
+ onSelectTime(option as TimerOption)}
+ suffix="s"
+ />
+ ) : selectedMode === "quotes" ? (
+
+ onSelectQuoteLen(option as "short" | "med" | "long" | "xl")
+ }
+ />
+ ) : (
+
+ onSelectRandomTextLen(option as TimerOption)
+ }
+ />
+ )}
+
+
{timeRemaining?.toFixed(0)}
+
+
+
+
-
{timeRemaining?.toFixed(0)}
-
);
@@ -93,32 +107,104 @@ export const TypingArea = ({
const TextDisplay = ({
userInput,
sampleText,
+ cursorStyle = "block",
}: {
userInput: string;
sampleText: string;
+ cursorStyle?: CursorStyle;
}) => {
const containerRef = useRef
(null);
+ const cursorRef = useRef(null);
+ const [cursorPosition, setCursorPosition] = useState({ left: 0, top: 0 });
const LINE_HEIGHT = 30;
const VISIBLE_LINES = 3;
+ const getCursorStyles = (currentSpan: HTMLElement) => {
+ const spanRect = currentSpan.getBoundingClientRect();
+
+ switch (cursorStyle) {
+ case "block":
+ return {
+ width: `${spanRect.width + 0.5}px`,
+ height: `${spanRect.height}px`,
+ backgroundColor: "hsl(var(--card))",
+ mixBlendMode: "difference",
+ };
+ case "line":
+ return {
+ width: "2px",
+ height: `${spanRect.height}px`,
+ backgroundColor: "hsl(var(--card))",
+ mixBlendMode: "difference",
+ };
+ case "underline":
+ return {
+ width: `${spanRect.width}px`,
+ height: "2px",
+ backgroundColor: "hsl(var(--card))", // Use white for inversion
+ mixBlendMode: "difference",
+ top: `${spanRect.height - 2}px`,
+ };
+ default:
+ return {};
+ }
+ };
+
+ useEffect(() => {
+ const cursor = cursorRef.current;
+ if (!cursor) return;
+
+ console.log("resetting anim");
+
+ // Reset animation
+ cursor.style.animation = "none";
+ cursor.offsetHeight;
+ cursor.style.animation =
+ cursorStyle === "block"
+ ? "blink 1s ease-in-out infinite"
+ : "blink 1s ease-in-out infinite";
+ }, [cursorPosition, cursorStyle]);
+
+ // Update cursor position when input changes
useEffect(() => {
const container = containerRef.current;
- if (!container) return;
+ const cursor = cursorRef.current;
+ if (!container || !cursor) return;
const currentCharIndex = userInput.length;
const spans = container.children;
- if (currentCharIndex >= spans.length) return;
- const currentSpan = spans[currentCharIndex] as HTMLElement;
+ // Skip the cursor element itself when getting spans
+ const textSpans = Array.from(spans).filter((span) => span !== cursor);
+
+ if (currentCharIndex >= textSpans.length) return;
+
+ const currentSpan = textSpans[currentCharIndex] as HTMLElement;
+ const containerRect = container.getBoundingClientRect();
+ const spanRect = currentSpan.getBoundingClientRect();
+
+ // Position cursor relative to container
+ const relativeLeft = spanRect.left - containerRect.left;
+ const relativeTop = spanRect.top - containerRect.top;
+
+ // force cursor rerender
+ setCursorPosition({ left: relativeLeft, top: relativeTop });
+
+ // Apply cursor styles
+ const cursorStyles = getCursorStyles(currentSpan);
+ Object.assign(cursor.style, {
+ left: `${relativeLeft}px`,
+ top: `${currentSpan.offsetTop}px`,
+ ...cursorStyles,
+ });
+
+ // Handle scrolling
const spanOffsetTop = currentSpan.offsetTop;
const maxScrollTop = container.scrollHeight - container.clientHeight;
- const desiredScrollTop = Math.min(
- Math.max(0, spanOffsetTop - LINE_HEIGHT),
- maxScrollTop,
- );
+ const desiredScrollTop = Math.min(Math.max(0, spanOffsetTop), maxScrollTop);
container.scrollTop = desiredScrollTop;
- }, [userInput.length, sampleText]);
+ }, [userInput.length, sampleText, cursorStyle]);
return (
- {sampleText
- .split("")
- .map(
- (
- char:
- | string
- | number
- | bigint
- | boolean
- | object
- | ComponentChild[]
- | VNode
- | null
- | undefined,
- i: number,
- ) => (
-
+
+ {sampleText.split("").map((char, i) => (
+
- {char}
-
- ),
- )}
+ >
+ {char}
+
+ ))}
);
diff --git a/src/components/smartSearchBar.tsx b/src/components/smartSearchBar.tsx
index 6a8ff72..95eafc2 100644
--- a/src/components/smartSearchBar.tsx
+++ b/src/components/smartSearchBar.tsx
@@ -17,7 +17,7 @@ import { Button } from "./ui/button";
// return "unknown";
// }
-function isOnMac() {
+export function isOnMac() {
return navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
}
diff --git a/src/components/ui/kbdKey.tsx b/src/components/ui/kbdKey.tsx
index a5910b0..9d59597 100644
--- a/src/components/ui/kbdKey.tsx
+++ b/src/components/ui/kbdKey.tsx
@@ -44,8 +44,6 @@ const KbdKey = React.forwardRef<
// Check if all keys are pressed
const allKeysPressed = keyStates.every((state) => state === true);
- console.log(keyStates);
-
return (
(key: string, value: T): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.error(`Error saving to localStorage (${key}):`, error);
+ }
+}
+
+/**
+ * Gets a value from localStorage with JSON parsing
+ */
+export function getFromLocalStorage(key: string, defaultValue: T): T {
+ try {
+ const item = localStorage.getItem(key);
+
+ if (item === null) {
+ setToLocalStorage(key, defaultValue);
+ return defaultValue;
+ }
+
+ return JSON.parse(item) as T;
+ } catch (error) {
+ console.error(`Error reading from localStorage (${key}):`, error);
+ return defaultValue;
+ }
+}
+
+/**
+ * Custom hook that syncs state with localStorage
+ */
+export function useStoredState(
+ key: string,
+ defaultValue: T,
+): [T, Dispatch>] {
+ const [value, setValue] = useState(() =>
+ getFromLocalStorage(key, defaultValue),
+ );
+
+ useEffect(() => {
+ console.log("useStoredState", key, value);
+ setToLocalStorage(key, value);
+ }, [key, value]);
+
+ return [value, setValue];
+}
diff --git a/src/index.css b/src/index.css
index ad3c76f..93cb10c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -172,7 +172,7 @@
animation-delay: -0.1s;
}
-@keyframes invertBlink {
+@keyframes blink {
0% {
filter: invert(100%);
}
@@ -191,7 +191,7 @@
}
.animate-blink {
- animation: invertBlink 1s infinite;
+ animation: blink 1s infinite;
}
.scrollbar-hide {
diff --git a/src/routes/rnfgrertt/typing.lazy.tsx b/src/routes/rnfgrertt/typing.lazy.tsx
index 4f015e4..e7e2d29 100644
--- a/src/routes/rnfgrertt/typing.lazy.tsx
+++ b/src/routes/rnfgrertt/typing.lazy.tsx
@@ -1,5 +1,6 @@
import { calculateStats } from "@/components/rnfgrertt/calculateStats";
import { UPDATE_INTERVAL } from "@/components/rnfgrertt/constants";
+import { HelpModal } from "@/components/rnfgrertt/helpModal";
import { useTypingMetricsTracker } from "@/components/rnfgrertt/hooks/useStatsTracker";
import { useTypingTest } from "@/components/rnfgrertt/hooks/useTypingTest";
import { useWpmTracker } from "@/components/rnfgrertt/hooks/useWpmTracker";
@@ -9,11 +10,15 @@ import {
getRandomText,
} from "@/components/rnfgrertt/textGenerator";
import {
+ CursorStyle,
TextMeta,
TimerOption,
TypingStats,
} from "@/components/rnfgrertt/types";
import { TypingArea } from "@/components/rnfgrertt/typingArea";
+import { isOnMac } from "@/components/smartSearchBar";
+import { KbdKey } from "@/components/ui/kbdKey";
+import { useStoredState } from "@/hooks/useStoredState";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState, useMemo, useEffect } from "preact/hooks";
@@ -21,26 +26,43 @@ export const Route = createLazyFileRoute("/rnfgrertt/typing")({
component: TypingTest,
});
-const useKeyboardShortcuts = (resetCallback: () => void) => {
+const useKeyboardShortcuts = (
+ resetCallback: () => void,
+ cursorStyle: CursorStyle,
+ toggleCursorStyle: (style: CursorStyle) => void,
+ toggleHelp: () => void,
+) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- // Alternative: Reset on Escape key
- if (e.key === "Escape") {
+ // Help menu on Ctrl/Cmd + H
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "h") {
+ e.preventDefault();
+ toggleHelp();
+ }
+ // Existing shortcuts
+ if (e.shiftKey && e.key === "Escape") {
e.preventDefault();
resetCallback();
}
+ if (e.key === "-") {
+ e.preventDefault();
+ if (cursorStyle === "block") toggleCursorStyle("line");
+ else if (cursorStyle === "line") toggleCursorStyle("underline");
+ else toggleCursorStyle("block");
+ }
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [resetCallback]);
+ }, [resetCallback, toggleHelp]);
};
export interface TestConfig {
mode: "random" | "quotes" | "time";
selectedTimer: TimerOption | null;
randomLen: TimerOption | null;
- quoteLen: "short" | "med" | "long";
+ quoteLen: "short" | "med" | "long" | "xl";
+ cursorStyle: CursorStyle;
}
// Initial state constants
@@ -49,6 +71,7 @@ const DEFAULT_CONFIG: TestConfig = {
selectedTimer: 30,
randomLen: 30,
quoteLen: "short",
+ cursorStyle: "block",
};
const INITIAL_WORD_COUNT = {
@@ -58,13 +81,20 @@ const INITIAL_WORD_COUNT = {
function TypingTest() {
// Group related state
- const [config, setConfig] = useState(DEFAULT_CONFIG);
+ const [config, setConfig] = useStoredState(
+ "atp-tools-typing-test-config",
+ DEFAULT_CONFIG,
+ );
const [currentTextMeta, setCurrentText] = useState(
config.mode === "time"
? generateWords(INITIAL_WORD_COUNT.time)
: getRandomText(config.quoteLen),
);
+ const [showHelp, setShowHelp] = useState(false);
+
+ const toggleHelp = () => setShowHelp((prev) => !prev);
+
// Helper functions
const getCurrentText = (textMeta: string | TextMeta): string => {
return typeof textMeta === "object" ? textMeta.text : textMeta;
@@ -90,7 +120,7 @@ function TypingTest() {
setConfig((prev) => ({ ...prev, selectedTimer: timer }));
};
- const handleQuoteLenChange = (length: "short" | "med" | "long") => {
+ const handleQuoteLenChange = (length: "short" | "med" | "long" | "xl") => {
setConfig((prev) => ({ ...prev, quoteLen: length }));
};
@@ -102,6 +132,10 @@ function TypingTest() {
typingTest.handleInput((e.target as any)?.value);
};
+ const setCursorStyle = (style: CursorStyle) => {
+ setConfig((prev) => ({ ...prev, cursorStyle: style }));
+ };
+
const currentText = getCurrentText(currentTextMeta);
const typingTest = useTypingTest(
@@ -137,7 +171,12 @@ function TypingTest() {
}, [config.selectedTimer, config.randomLen, config.quoteLen, config.mode]);
// Keyboard shortcuts
- useKeyboardShortcuts(resetAll);
+ useKeyboardShortcuts(
+ resetAll,
+ config.cursorStyle,
+ setCursorStyle,
+ toggleHelp,
+ );
const metricsHistory = useTypingMetricsTracker(
typingTest.userInput,
@@ -176,9 +215,6 @@ function TypingTest() {
).length, // Remove the division by (UPDATE_INTERVAL / 250)
}));
}, [wpmData, typingTest.errors, typingTest.startTime]);
-
- console.log(chartData.filter((f) => f.errorsPerSecond > 0));
-
return (
{typingTest.isFinished ? (
@@ -203,8 +239,10 @@ function TypingTest() {
onSelectQuoteLen={handleQuoteLenChange}
randomTextLen={config.randomLen}
onSelectRandomTextLen={handleRandomLenChange}
+ cursorStyle={config.cursorStyle}
/>
)}
+ setShowHelp(false)} />
);
}