mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-23 18:32:39 +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
46
src/lib/util/debounce.ts
Normal file
46
src/lib/util/debounce.ts
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
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