Skip to content

Commit

Permalink
feat: add typing practice page (#556)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Jan 8, 2024
1 parent 34179ef commit 38a2dbc
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 5 deletions.
32 changes: 32 additions & 0 deletions app/e2e/bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { expect } from "@playwright/test";
import { importSeed } from "../misc/seed-utils";
import { test } from "./coverage";
import { useUserE2E, waitForHydration } from "./helper";
Expand Down Expand Up @@ -50,6 +51,37 @@ test.describe("bookmarks", () => {
await page.getByRole("button", { name: "Go to Last Bookmark" }).click();
await page.getByText("케플러 대박 기원").click();
});

test("typing", async ({ page }) => {
await user.signin(page);
await page.goto("/bookmarks");
await waitForHydration(page);

// open bookmark
await page.getByText("케플러 대박 기원").click();

// click typing pratcie link
const page2Promise = page.waitForEvent("popup");
await page.getByTestId("typing-link").click();
const page2 = await page2Promise;
await waitForHydration(page2);

// wrong input 플 != 푸 and check highlight
await page2.locator('textarea[name="answer"]').fill("케푸러");
const result = await page2
.getByTestId("typing-mismatch-overlay")
.evaluate((node) => {
return Array.from(node.childNodes).map((c) => {
const el = c as HTMLElement;
return [el.textContent, Boolean(el.className)];
});
});
expect(result).toEqual([
["케", false],
["플", true],
["러", false],
]);
});
});

test.describe("/bookmarks/history-chart", () => {
Expand Down
5 changes: 5 additions & 0 deletions app/misc/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const ROUTE_DEF = {
v: z.string(),
}),
},
"/typing": {
query: z.object({
test: z.string(),
}),
},
"/videos/$id": {
params: Z_ID_PARAMS,
query: z.object({
Expand Down
86 changes: 86 additions & 0 deletions app/routes/typing/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useTinyForm } from "@hiogawa/tiny-form/dist/react";
import { zip } from "@hiogawa/utils";
import { z } from "zod";
import { useUrlQuerySchema } from "../../utils/loader-utils";
import { cls } from "../../utils/misc";
import { PageHandle } from "../../utils/page-handle";

export const handle: PageHandle = {
navBarTitle: () => "Typing Practice",
};

export default function Page() {
const [query, setQuery] = useUrlQuerySchema(
z.object({
test: z
.string()
.default("")
.transform((s) => s.trim().split(/\s+/).join(" ")),
})
);
const form = useTinyForm({ test: query.test, answer: "" });

const matches = zip([...form.data.test], [...form.data.answer]).map(
([x, y]) => ({
ok: x === y,
x,
y,
})
);
const ok = matches.every((m) => m.ok);

return (
<div className="w-full flex justify-center">
<div className="w-full max-w-2xl flex flex-col">
<div className="p-6 flex flex-col gap-3">
<form
className="w-full flex flex-col gap-5"
onSubmit={form.handleSubmit(() => {})}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">Reference</div>
<div className="flex flex-col relative">
<textarea
className={cls(
"antd-input p-1",
!ok && "border-colorErrorBorderHover"
)}
value={form.fields.test.value}
onChange={(e) => {
const value = e.target.value;
form.fields.test.onChange(value);
setQuery({ test: value }, { replace: true });
}}
/>
{/* use "div" and "span" with same geometry to highlight mismatch over textarea */}
{/* TODO: how to hande new line? (or filter out new line from test input?) */}
<div
className="absolute pointer-events-none absolute p-1 border border-transparent text-transparent"
data-testid="typing-mismatch-overlay"
>
{matches.map((m, i) => (
<span
key={i}
className={cls(
!m.ok && "border-b-2 border-colorErrorText opacity-80"
)}
>
{m.x}
</span>
))}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">Practice Input</div>
<textarea
className="antd-input p-1"
{...form.fields.answer.props()}
/>
</div>
</form>
</div>
</div>
</div>
);
}
4 changes: 2 additions & 2 deletions app/routes/videos/_ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ export function CaptionEntryComponent({
/>
)}
<a
// prettier-ignore
href={`https://10fastfingers.com/widget/typingtest?dur=600&rand=0&words=${encodeURIComponent(entry.text1)}`}
href={$R["/typing"](null, { test: entry.text1 })}
// use "media-mouse" as keyboard detection heuristics https://github.com/w3c/csswg-drafts/issues/3871
className="antd-btn antd-btn-ghost i-ri-keyboard-line w-4 h-4 hidden media-mouse:inline"
target="_blank"
data-testid="typing-link"
/>
<button
className={cls(
Expand Down
9 changes: 6 additions & 3 deletions app/utils/loader-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ function useUrlQuery() {
[params]
);

function set(next: Record<string, unknown>): void {
setParams(merge(next));
function set(
next: Record<string, unknown>,
opts?: { replace?: boolean }
): void {
setParams(merge(next), opts);
}

function merge(next: Record<string, unknown>): URLSearchParams {
Expand Down Expand Up @@ -71,7 +74,7 @@ export function useUrlQuerySchema<Schame extends z.AnyZodObject>(
return result.success ? result.data : schema.parse({});
}, [query]);

const set = (next: I) => setQuery(next);
const set = (next: I, opts?: { replace?: boolean }) => setQuery(next, opts);

const merge = (next: I) => mergeParams(next);

Expand Down

0 comments on commit 38a2dbc

Please sign in to comment.