[#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

46
src/lib/util/debounce.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Creates a debounced function that delays invoking the provided function
* until after 'wait' milliseconds have elapsed since the last time it was
* invoked.
*
* I could use _.debounce(), but bringing dependency on lodash didn't feel
* justified yet.
*
* @param func The function to debounce
* @param wait The number of milliseconds to delay execution
* @returns A debounced version of the provided function
*/
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): T & { cancel: () => void } {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = function(
this: ThisParameterType<T>, ...args: Parameters<T>
): void {
const context = this;
const later = function() {
timeout = null;
func.apply(context, args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
debounced.cancel = function() {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced as T & { cancel: () => void };
}
export default debounce;

View File

@@ -2,6 +2,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte"; import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import debounce from "$lib/util/debounce";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder"; import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle"; import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition"; import { fade, fly, slide } from "svelte/transition";
@@ -12,6 +13,34 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { expoOut } from "svelte/easing"; import { expoOut } from "svelte/easing";
import { goto } from "$app/navigation"; 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) { function viaLocalStorage<T>(key: string, initial: T) {
try { 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( let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [ viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"], [1500, 1050, "Words"],
@@ -29,28 +63,36 @@
]), ]),
); );
const avgWordLength = 5;
function reset() { function reset() {
localStorage.removeItem("mastery-thresholds"); localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout"); localStorage.removeItem("idle-timeout");
window.location.reload(); window.location.reload();
} }
let inputSentence = $derived( const inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World", 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 chordInputContainer: HTMLDivElement | null = null;
let totalMs = $derived(inputSentence.length * msPerChar);
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( let msPerWord = $derived(
(inputSentence.length * msPerChar) / inputSentence.split(" ").length, (inputSentenceLength * msPerChar) / sentenceWords.length,
); );
let currentWord = $state(""); let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>(); let wordStats = new SvelteMap<string, number[]>();
@@ -90,7 +132,7 @@
}); });
let words = $derived.by(() => { let words = $derived.by(() => {
const words = inputSentence.trim().split(" "); const words = sentenceWords;
switch (level) { switch (level) {
case 0: { case 0: {
shuffleInPlace(words); shuffleInPlace(words);
@@ -160,18 +202,16 @@
}); });
function selectNextWord() { function selectNextWord() {
const unmasteredWords = words const nextWord = pickNextWord(
.map((it) => [it, wordMastery.get(it) ?? 0] as const) words,
.filter(([, it]) => it !== 1); wordMastery,
unmasteredWords.sort(([, a], [, b]) => a - b); untrack(() => currentWord),
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR"; );
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
currentWord = nextWord; currentWord = nextWord;
recorder = new ReplayRecorder(nextWord); recorder = new ReplayRecorder(nextWord);
setTimeout(() => {
chordInputContainer?.focus();
}, CURSOR_FOCUS_DELAY_MS);
} }
function checkInput() { function checkInput() {
@@ -215,19 +255,38 @@
idle = true; idle = true;
}, idleTime); }, 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> </script>
<div> <div>
<h1>Sentence Trainer</h1> <h1>Sentence Trainer</h1>
<input <textarea
type="text" rows="7"
value={inputSentence} cols="80"
onchange={(it) => { oninput={debouncedUpdateSentence}
const params = new URLSearchParams(window.location.search); onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
params.set("sentence", (it.target as HTMLInputElement).value); >
goto(`?${params.toString()}`);
}}
/>
<div class="levels"> <div class="levels">
{#each masteryThresholds as [, , title], i} {#each masteryThresholds as [, , title], i}
@@ -371,6 +430,7 @@
<ChordHud {chords} /> <ChordHud {chords} />
<div class="container"> <div class="container">
<div <div
bind:this={chordInputContainer}
class="input-section" class="input-section"
onkeydown={onkey} onkeydown={onkey}
onkeyup={onkey} onkeyup={onkey}
@@ -398,24 +458,24 @@
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span >{Math.round(totalMs)}</span
>ms</td >ms
> </td>
</tr> </tr>
<tr> <tr>
<th>Char</th> <th>Char</th>
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span >{Math.round(msPerChar)}</span
>ms</td >ms
> </td>
</tr> </tr>
<tr> <tr>
<th>Word</th> <th>Word</th>
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span >{Math.round(msPerWord)}</span
>ms</td >ms
> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -440,8 +500,9 @@
<td <td
style:color="var(--md-sys-color-{mastery === 1 style:color="var(--md-sys-color-{mastery === 1
? 'primary' ? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td : 'tertiary'})"
> >{Math.round(mastery * 100)}%
</td>
{#each stats as stat} {#each stats as stat}
<td>{stat}</td> <td>{stat}</td>
{/each} {/each}
@@ -560,6 +621,7 @@
opacity: 0; opacity: 0;
} }
} }
.input { .input {
display: flex; display: flex;
grid-row: 1; grid-row: 1;
@@ -577,6 +639,7 @@
.input-section:focus-within { .input-section:focus-within {
outline: none; outline: none;
.input { .input {
outline-color: var(--md-sys-color-primary); outline-color: var(--md-sys-color-primary);
border-radius: 1rem; border-radius: 1rem;
@@ -586,11 +649,4 @@
opacity: 1; opacity: 1;
} }
} }
input[type="text"] {
background: none;
color: inherit;
font: inherit;
border: none;
}
</style> </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;
}