mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-09 03:22:49 +00:00
[#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:
committed by
GitHub
parent
7f27499003
commit
0e5640a1ee
@@ -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>
|
||||
|
||||
29
src/routes/(app)/learn/sentence/configuration.ts
Normal file
29
src/routes/(app)/learn/sentence/configuration.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
8
src/routes/(app)/learn/sentence/constants.ts
Normal 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";
|
||||
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal file
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user