[#167] Expand textarea for sentence input; use untrack to break recursive reactivity loops hanging the page on long sentences; Use better error message instead of ERROR (#182)

This commit is contained in:
Aleksandr Iushmanov
2025-04-22 13:25:44 +01:00
committed by GitHub
parent 7f27499003
commit 0e5640a1ee
6 changed files with 280 additions and 47 deletions

View File

@@ -2,6 +2,7 @@
import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import debounce from "$lib/util/debounce";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition";
@@ -12,6 +13,34 @@
import { browser } from "$app/environment";
import { expoOut } from "svelte/easing";
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import {
type PageParam,
SENTENCE_TRAINER_PAGE_PARAMS,
} from "./configuration";
import {
AVG_WORD_LENGTH,
MILLIS_IN_SECOND,
SECONDS_IN_MINUTE,
} from "./constants";
import { pickNextWord } from "./word-selector";
/**
* Resolves parameter from search URL or returns default
* @param param {@link PageParam} generic parameter that can be provided
* in search url
* @return Value of the parameter converted to its type or default value
* if parameter is not present in the URL.
*/
function getParamOrDefault<T>(param: PageParam<T>): T {
if (browser) {
const value = $page.url.searchParams.get(param.key);
if (null !== value) {
return param.parse ? param.parse(value) : (value as unknown as T);
}
}
return param.default;
}
function viaLocalStorage<T>(key: string, initial: T) {
try {
@@ -21,6 +50,11 @@
}
}
// Delay to ensure cursor is visible after focus is set.
// it is a workaround for conflict between goto call on sentence update
// and cursor focus when next word is selected.
const CURSOR_FOCUS_DELAY_MS = 10;
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"],
@@ -29,28 +63,36 @@
]),
);
const avgWordLength = 5;
function reset() {
localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout");
window.location.reload();
}
let inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
const inputSentence = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
);
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
const wpmTarget = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
);
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true",
const devTools = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
);
let sentenceWords = $derived(inputSentence.split(" "));
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
let totalMs = $derived(inputSentence.length * msPerChar);
let chordInputContainer: HTMLDivElement | null = null;
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
let inputSentenceLength = $derived(inputSentence.length);
let msPerChar = $derived(
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
MILLIS_IN_SECOND,
);
let totalMs = $derived(inputSentenceLength * msPerChar);
let msPerWord = $derived(
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
(inputSentenceLength * msPerChar) / sentenceWords.length,
);
let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>();
@@ -90,7 +132,7 @@
});
let words = $derived.by(() => {
const words = inputSentence.trim().split(" ");
const words = sentenceWords;
switch (level) {
case 0: {
shuffleInPlace(words);
@@ -160,18 +202,16 @@
});
function selectNextWord() {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
const nextWord = pickNextWord(
words,
wordMastery,
untrack(() => currentWord),
);
currentWord = nextWord;
recorder = new ReplayRecorder(nextWord);
setTimeout(() => {
chordInputContainer?.focus();
}, CURSOR_FOCUS_DELAY_MS);
}
function checkInput() {
@@ -215,19 +255,38 @@
idle = true;
}, idleTime);
}
function updateSentence(event: Event) {
const params = new URLSearchParams(window.location.search);
params.set(
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
(event.target as HTMLInputElement).value,
);
goto(`?${params.toString()}`);
}
const debouncedUpdateSentence = debounce(
updateSentence,
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
);
function handleInputAreaKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent new line.
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
updateSentence(event); // Update immediately
}
}
</script>
<div>
<h1>Sentence Trainer</h1>
<input
type="text"
value={inputSentence}
onchange={(it) => {
const params = new URLSearchParams(window.location.search);
params.set("sentence", (it.target as HTMLInputElement).value);
goto(`?${params.toString()}`);
}}
/>
<textarea
rows="7"
cols="80"
oninput={debouncedUpdateSentence}
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
>
<div class="levels">
{#each masteryThresholds as [, , title], i}
@@ -371,6 +430,7 @@
<ChordHud {chords} />
<div class="container">
<div
bind:this={chordInputContainer}
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
@@ -398,24 +458,24 @@
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span
>ms</td
>
>ms
</td>
</tr>
<tr>
<th>Char</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span
>ms</td
>
>ms
</td>
</tr>
<tr>
<th>Word</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span
>ms</td
>
>ms
</td>
</tr>
</tbody>
</table>
@@ -440,8 +500,9 @@
<td
style:color="var(--md-sys-color-{mastery === 1
? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td
>
: 'tertiary'})"
>{Math.round(mastery * 100)}%
</td>
{#each stats as stat}
<td>{stat}</td>
{/each}
@@ -560,6 +621,7 @@
opacity: 0;
}
}
.input {
display: flex;
grid-row: 1;
@@ -577,6 +639,7 @@
.input-section:focus-within {
outline: none;
.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
@@ -586,11 +649,4 @@
opacity: 1;
}
}
input[type="text"] {
background: none;
color: inherit;
font: inherit;
border: none;
}
</style>

View File

@@ -0,0 +1,29 @@
export interface PageParam<T> {
key: string;
default: T;
parse?: (value: string) => T;
}
export const SENTENCE_TRAINER_PAGE_PARAMS: {
sentence: PageParam<string>;
wpm: PageParam<number>;
showDevTools: PageParam<boolean>;
textAreaDebounceInMillis: PageParam<number>;
} = {
sentence: { key: "sentence", default: "This text has been typed at the speed of thought" },
wpm: {
key: "wpm",
default: 250,
parse: (value) => Number(value),
},
showDevTools: {
key: "dev",
default: false,
parse: (value) => value === "true",
},
textAreaDebounceInMillis: {
key: "debounceMillis",
default: 5000,
parse: (value) => Number(value),
},
};

View File

@@ -0,0 +1,8 @@
// Domain constants
export const AVG_WORD_LENGTH = 5;
export const SECONDS_IN_MINUTE = 60;
export const MILLIS_IN_SECOND = 1000;
// Error messages.
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
"The sentence is too short to make N-Grams, please enter longer sentence";

View File

@@ -0,0 +1,69 @@
import { describe, it, beforeEach, expect, vi } from "vitest";
import { pickNextWord } from "./word-selector";
import { untrack } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
vi.mock("svelte", () => ({
untrack: vi.fn((fn: any) => fn()),
}));
describe("pickNextWord", () => {
let words: string[];
let wordMastery: SvelteMap<string, number>;
let currentWord: string;
beforeEach(() => {
vi.clearAllMocks();
// Set up sample words and mastery values.
words = ["alpha", "beta", "gamma"];
wordMastery = new SvelteMap<string, number>();
// For this test, assume none of the words are mastered.
words.forEach((word) => wordMastery.set(word, 0));
currentWord = "alpha";
});
it("should return a word different from current", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since currentWord ("alpha") should be skipped, we expect next word.
expect(nextWord).toBe("beta");
});
it("should randomly skip words", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since currentWord ("alpha") should be skipped as current
// and "beta" should be randomly skipped we expect "gamma".
expect(nextWord).toBe("gamma");
});
it("should return current word if all other words were randomly skipped", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since all other words have been randomly skipped, we expect
// current word to be returned.
expect(nextWord).toBe("alpha");
});
it("current word should be passed untracked", () => {
pickNextWord(words, wordMastery, currentWord);
expect(untrack).toHaveBeenCalledTimes(0);
});
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
const result = pickNextWord([], wordMastery, currentWord);
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
});
});

View File

@@ -0,0 +1,25 @@
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
import { SvelteMap } from "svelte/reactivity";
export function pickNextWord(
words: string[],
wordMastery: SvelteMap<string, number>,
untrackedCurrentWord: string,
) {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord =
unmasteredWords[0]?.[0] ??
words[0] ??
TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE;
// This is important to break infinite loop created by
// reading and writing `currentWord` inside $effect rune
for (const [word] of unmasteredWords) {
if (word === untrackedCurrentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
return nextWord;
}