Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/lib/util/debounce.ts
Original file line number Diff line number Diff line change
@@ -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;
150 changes: 103 additions & 47 deletions src/routes/(app)/learn/sentence/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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"],
Expand All @@ -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),
);

const wpmTarget = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
);
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250,

const devTools = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
);
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true",

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 sentenceWords = $derived(inputSentence.split(" "));
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
let totalMs = $derived(inputSentence.length * msPerChar);
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[]>();
Expand Down Expand Up @@ -90,7 +132,7 @@
});

let words = $derived.by(() => {
const words = inputSentence.trim().split(" ");
const words = sentenceWords;
switch (level) {
case 0: {
shuffleInPlace(words);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -371,6 +430,7 @@
<ChordHud {chords} />
<div class="container">
<div
bind:this={chordInputContainer}
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
Expand Down Expand Up @@ -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>
Expand All @@ -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}
Expand Down Expand Up @@ -560,6 +621,7 @@
opacity: 0;
}
}

.input {
display: flex;
grid-row: 1;
Expand All @@ -577,6 +639,7 @@

.input-section:focus-within {
outline: none;

.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
Expand All @@ -586,11 +649,4 @@
opacity: 1;
}
}

input[type="text"] {
background: none;
color: inherit;
font: inherit;
border: none;
}
</style>
29 changes: 29 additions & 0 deletions src/routes/(app)/learn/sentence/configuration.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions src/routes/(app)/learn/sentence/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading