Skip to content

Commit acbb1fc

Browse files
committed
feat: replay a typing test
1 parent 09ed67e commit acbb1fc

22 files changed

+1731
-1470
lines changed

package-lock.json

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/keybr-widget/lib/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./use-animation-frames.ts";
12
export * from "./use-clipboard.ts";
23
export * from "./use-debounced.ts";
34
export * from "./use-deferred.tsx";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useRef } from "react";
2+
import { AnimationFrames } from "../utils/index.ts";
3+
4+
export const useAnimationFrames = () => {
5+
const ref = useRef<AnimationFrames>(null!);
6+
if (ref.current == null) {
7+
ref.current = new AnimationFrames();
8+
}
9+
useEffect(() => {
10+
return () => {
11+
ref.current.cancel();
12+
};
13+
}, []);
14+
return ref.current;
15+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export class AnimationFrames {
2+
#id = 0;
3+
#started = 0;
4+
5+
start(callback: (elapsed: DOMHighResTimeStamp) => void | boolean) {
6+
this.cancel();
7+
this.#started = 0;
8+
const step = (time: DOMHighResTimeStamp) => {
9+
if (this.#started === 0) {
10+
this.#started = time;
11+
}
12+
if (callback(time - this.#started) !== false) {
13+
this.#id = requestAnimationFrame(step);
14+
}
15+
};
16+
this.#id = requestAnimationFrame(step);
17+
}
18+
19+
cancel() {
20+
if (this.#id) {
21+
cancelAnimationFrame(this.#id);
22+
}
23+
this.#id = 0;
24+
}
25+
}

packages/keybr-widget/lib/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./animationframes.ts";
12
export * from "./format-duration.ts";
23
export * from "./geometry.ts";
34
export * from "./point.ts";
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[*.test.*]
2+
max_line_length = 120

packages/page-typing-test/lib/TypingTestPage.tsx

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useSettings } from "@keybr/settings";
22
import { makeStats } from "@keybr/textinput";
3-
import { type ReactNode, useMemo, useState } from "react";
3+
import { type ReactNode, useState } from "react";
44
import { Report } from "./components/Report.tsx";
55
import { SettingsScreen } from "./components/SettingsScreen.tsx";
66
import { TestScreen } from "./components/TestScreen.tsx";
@@ -15,10 +15,7 @@ export function TypingTestPage(): ReactNode {
1515
Settings,
1616
}
1717
const { settings } = useSettings();
18-
const compositeSettings = useMemo(
19-
() => toCompositeSettings(settings),
20-
[settings],
21-
);
18+
const compositeSettings = toCompositeSettings(settings);
2219
const [view, setView] = useState(View.Test);
2320
const [result, setResult] = useState(emptyResult());
2421
switch (view) {
@@ -46,6 +43,7 @@ export function TypingTestPage(): ReactNode {
4643
case View.Report:
4744
return (
4845
<Report
46+
settings={compositeSettings}
4947
result={result}
5048
onNext={() => {
5149
setView(View.Test);
@@ -66,13 +64,17 @@ export function TypingTestPage(): ReactNode {
6664
function emptyResult(): TestResult {
6765
return {
6866
stats: makeStats([]),
67+
steps: [],
6968
events: [],
7069
};
7170
}
7271

7372
function makeResult(session: Session): TestResult {
73+
const steps = session.getSteps();
74+
const events = session.getEvents();
7475
return {
75-
stats: makeStats(session.getSteps()),
76-
events: session.getEvents(),
76+
stats: makeStats(steps),
77+
steps,
78+
events,
7779
};
7880
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useFormatter } from "@keybr/lesson-ui";
2+
import {
3+
AnimationFrames,
4+
formatDuration,
5+
NameValue,
6+
Para,
7+
} from "@keybr/widget";
8+
import { useEffect, useState } from "react";
9+
import { type ReplayState } from "../session/index.ts";
10+
11+
export function Progress({ stepper }: { readonly stepper: ReplayState }) {
12+
const { formatSpeed } = useFormatter();
13+
const { progress, time } = useProgress(stepper);
14+
return (
15+
<Para align="center">
16+
<NameValue
17+
name="Progress"
18+
value={`${progress.progress}/${progress.length}`}
19+
/>
20+
<NameValue
21+
name="Time"
22+
value={formatDuration(time, { showMillis: true })}
23+
/>
24+
<NameValue name="Speed" value={formatSpeed(progress.speed)} />
25+
</Para>
26+
);
27+
}
28+
29+
function useProgress(stepper: ReplayState) {
30+
const { state, progress } = stepper;
31+
const [time, setTime] = useState(0);
32+
useEffect(() => {
33+
setTime(0);
34+
const frames = new AnimationFrames();
35+
frames.start(() => {
36+
if (state === "running" || state === "finished") {
37+
setTime(progress.time);
38+
}
39+
});
40+
return () => {
41+
frames.cancel();
42+
};
43+
}, [state, progress]);
44+
return { progress, time };
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.root {
2+
margin-block: 1rem;
3+
}
4+
5+
.text {
6+
margin-block: 1rem;
7+
}
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,75 @@
11
import { type KeyId, useKeyboard } from "@keybr/keyboard";
22
import { KeyLayer, VirtualKeyboard } from "@keybr/keyboard-ui";
3+
import { Tasks } from "@keybr/lang";
4+
import {
5+
type LineList,
6+
type Step,
7+
type TextInputSettings,
8+
} from "@keybr/textinput";
39
import { type AnyEvent } from "@keybr/textinput-events";
10+
import { StaticText } from "@keybr/textinput-ui";
11+
import { Box, useDocumentVisibility } from "@keybr/widget";
412
import { type ReactNode, useEffect, useMemo, useState } from "react";
5-
import { ReplayState } from "../session/index.ts";
13+
import { ReplayState, Session, type TestResult } from "../session/index.ts";
14+
import { type CompositeSettings } from "../settings.ts";
15+
import { Progress } from "./Progress.tsx";
16+
import * as styles from "./Replay.module.less";
617

718
export function Replay({
8-
events,
19+
settings: { textInput, textDisplay },
20+
result: { steps, events },
921
}: {
10-
readonly events: readonly AnyEvent[];
22+
readonly settings: CompositeSettings;
23+
readonly result: TestResult;
1124
}): ReactNode {
1225
const keyboard = useKeyboard();
13-
const depressedKeys = useReplayState(events);
26+
const { stepper, lines, depressedKeys } = useReplayState(
27+
textInput,
28+
steps,
29+
events,
30+
);
1431
return (
15-
<VirtualKeyboard keyboard={keyboard} height="16rem">
16-
<KeyLayer depressedKeys={depressedKeys} />
17-
</VirtualKeyboard>
32+
<div className={styles.root}>
33+
<Progress stepper={stepper} />
34+
<Box className={styles.text} alignItems="center" justifyContent="center">
35+
<StaticText settings={textDisplay} lines={lines} cursor={true} />
36+
</Box>
37+
<VirtualKeyboard keyboard={keyboard} height="16rem">
38+
<KeyLayer depressedKeys={depressedKeys} />
39+
</VirtualKeyboard>
40+
</div>
1841
);
1942
}
2043

21-
function useReplayState(events: readonly AnyEvent[]) {
22-
const stepper = useMemo(() => new ReplayState(events), [events]);
44+
function useReplayState(
45+
settings: TextInputSettings,
46+
steps: readonly Step[],
47+
events: readonly AnyEvent[],
48+
) {
49+
const stepper = useMemo(
50+
() => new ReplayState(settings, steps, events),
51+
[settings, steps, events],
52+
);
53+
const visible = useDocumentVisibility();
54+
const [lines, setLines] = useState<LineList>(Session.emptyLines);
2355
const [depressedKeys, setDepressedKeys] = useState<KeyId[]>([]);
2456
useEffect(() => {
25-
let id = 0;
57+
const tasks = new Tasks();
2658
const step = () => {
2759
stepper.step();
60+
setLines(stepper.lines);
2861
setDepressedKeys(stepper.depressedKeys);
29-
id = window.setTimeout(step, stepper.delay);
62+
tasks.delayed(stepper.delay, step);
3063
};
31-
setDepressedKeys(stepper.depressedKeys);
32-
id = window.setTimeout(step, stepper.delay);
64+
if (visible) {
65+
stepper.reset();
66+
setLines(stepper.lines);
67+
setDepressedKeys(stepper.depressedKeys);
68+
tasks.delayed(stepper.delay, step);
69+
}
3370
return () => {
34-
window.clearTimeout(id);
71+
tasks.cancelAll();
3572
};
36-
}, [stepper]);
37-
return depressedKeys;
73+
}, [stepper, visible]);
74+
return { stepper, lines, depressedKeys };
3875
}

packages/page-typing-test/lib/components/Report.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ import {
2424
import { mdiSkipNext } from "@mdi/js";
2525
import { memo, type ReactNode } from "react";
2626
import { type TestResult } from "../session/index.ts";
27+
import { type CompositeSettings } from "../settings.ts";
2728
import { Replay } from "./Replay.tsx";
2829
import * as styles from "./Report.module.less";
2930

3031
export const Report = memo(function Report({
32+
settings,
3133
result,
3234
onNext,
3335
}: {
36+
readonly settings: CompositeSettings;
3437
readonly result: TestResult;
3538
readonly onNext: () => void;
3639
}) {
@@ -110,7 +113,9 @@ export const Report = memo(function Report({
110113
</Name>
111114
</Para>
112115

113-
<Replay events={result.events} />
116+
<Spacer size={3} />
117+
118+
<Replay settings={settings} result={result} />
114119

115120
<Spacer size={3} />
116121

packages/page-typing-test/lib/generators/TextGeneratorLoader.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BookContentLoader } from "@keybr/content-books";
22
import { WordListLoader } from "@keybr/content-words";
33
import { PhoneticModelLoader } from "@keybr/phonetic-model-loader";
4-
import { LCG, type RNGStream } from "@keybr/rand";
4+
import { LCG } from "@keybr/rand";
55
import { type ReactNode } from "react";
66
import { type TextSource, TextSourceType } from "../settings.ts";
77
import { BookParagraphsGenerator } from "./book.ts";
@@ -14,7 +14,7 @@ export function TextGeneratorLoader({
1414
children,
1515
}: {
1616
readonly textSource: TextSource;
17-
readonly children: (textGenerator: TextGenerator) => ReactNode;
17+
readonly children: (generator: TextGenerator) => ReactNode;
1818
}): ReactNode {
1919
switch (textSource.type) {
2020
case TextSourceType.CommonWords:
@@ -42,6 +42,6 @@ export function TextGeneratorLoader({
4242
}
4343
}
4444

45-
function rng(): RNGStream {
45+
function rng() {
4646
return LCG(Date.now());
4747
}

packages/page-typing-test/lib/generators/commonwords.test.ts

+3-19
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,12 @@
11
import { test } from "node:test";
2-
import { type WordList } from "@keybr/content";
32
import { FakeRNGStream } from "@keybr/rand";
43
import { assert } from "chai";
54
import { CommonWordsGenerator } from "./commonwords.ts";
65

76
test("generate words", () => {
8-
const wordList: WordList = [
9-
"one",
10-
"two",
11-
"three",
12-
"four",
13-
"five",
14-
"six",
15-
"seven",
16-
"eight",
17-
"nine",
18-
"ten",
19-
];
20-
const random = FakeRNGStream(wordList.length);
21-
const generator = new CommonWordsGenerator(
22-
{ wordListSize: 1000 },
23-
wordList,
24-
random,
25-
);
7+
const words = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
8+
const random = FakeRNGStream(words.length);
9+
const generator = new CommonWordsGenerator({ wordListSize: 1000 }, words, random);
2610

2711
const mark0 = generator.mark();
2812

packages/page-typing-test/lib/generators/pseudowords.test.ts

+1-12
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,7 @@ import { assert } from "chai";
55
import { PseudoWordsGenerator } from "./pseudowords.ts";
66

77
test("generate words", () => {
8-
const words = [
9-
"one",
10-
"two",
11-
"three",
12-
"four",
13-
"five",
14-
"six",
15-
"seven",
16-
"eight",
17-
"nine",
18-
"ten",
19-
];
8+
const words = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
209
const random = FakeRNGStream(words.length);
2110
const model = new FakePhoneticModel(words, LCG(1));
2211
const generator = new PseudoWordsGenerator(model, random);

0 commit comments

Comments
 (0)