mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-28 21:02:42 +00:00
Compare commits
11 Commits
v2.2.0
...
9266702cbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
9266702cbb
|
|||
|
77e2d2b20e
|
|||
|
7819f546a6
|
|||
|
e37b38085d
|
|||
|
a3bf9ac32b
|
|||
|
|
5bd3245084 | ||
|
1cd2ec318a
|
|||
|
6c8bfa0272
|
|||
|
f69be14b5e
|
|||
|
dce554fc66
|
|||
|
f152dbdcf5
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- name: 🐉 Use Node.js 22.4.x
|
- name: 🐉 Use Node.js 22.4.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
12
flake.nix
12
flake.nix
@@ -14,7 +14,13 @@
|
|||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [
|
||||||
|
(import rust-overlay)
|
||||||
|
(final: prev: {
|
||||||
|
nodejs = prev.nodejs_22;
|
||||||
|
corepack = prev.corepack_22;
|
||||||
|
})
|
||||||
|
];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [
|
extensions = [
|
||||||
@@ -46,8 +52,8 @@
|
|||||||
];
|
];
|
||||||
packages =
|
packages =
|
||||||
(with pkgs; [
|
(with pkgs; [
|
||||||
nodejs_22
|
nodejs
|
||||||
nodePackages.pnpm
|
pnpm
|
||||||
rust-bin
|
rust-bin
|
||||||
fontMin
|
fontMin
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "2.2.0",
|
"version": "2.2.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.16",
|
"node": ">=22.4",
|
||||||
"pnpm": ">=8.6"
|
"pnpm": ">=9.4"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "2.2.0"
|
version = "2.2.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "2.2.0" },
|
"package": { "productName": "amacc1ng", "version": "2.2.3" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
||||||
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
||||||
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
||||||
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
|
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
|
||||||
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
||||||
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||||
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||||
|
|||||||
20
src/lib/charrecorder/TrackText.svelte
Normal file
20
src/lib/charrecorder/TrackText.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { ReplayPlayer } from "./core/player";
|
||||||
|
import { TextPlugin } from "./core/plugins/text";
|
||||||
|
|
||||||
|
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||||
|
|
||||||
|
let { text = $bindable("") } = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!player.player) return;
|
||||||
|
const tracker = new TextPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
text = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { WpmReplayPlugin } from "./core/plugins/wpm";
|
||||||
|
import type { ReplayPlayer } from "./core/player";
|
||||||
|
|
||||||
|
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||||
|
|
||||||
|
let { wpm = $bindable(0) } = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!player.player) return;
|
||||||
|
const tracker = new WpmReplayPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
wpm = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(this.tokens);
|
|
||||||
|
|
||||||
clearTimeout(this.timeout);
|
clearTimeout(this.timeout);
|
||||||
if (replay.stepper.held.size === 0) {
|
if (replay.stepper.held.size === 0) {
|
||||||
|
|||||||
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReplayPlayer } from "../player";
|
||||||
|
import type { ReplayPlugin, StoreContract } from "../types";
|
||||||
|
|
||||||
|
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
|
||||||
|
private subscribers = new Set<(value: string) => void>();
|
||||||
|
|
||||||
|
register(replay: ReplayPlayer) {
|
||||||
|
replay.subscribe(() => {
|
||||||
|
if (this.subscribers.size === 0) return;
|
||||||
|
const text = replay.stepper.text
|
||||||
|
.filter((it) => it.source !== "ghost")
|
||||||
|
.map((it) => it.text)
|
||||||
|
.join("");
|
||||||
|
for (const subscription of this.subscribers) {
|
||||||
|
subscription(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subscribe(subscription: (value: string) => void) {
|
||||||
|
this.subscribers.add(subscription);
|
||||||
|
return () => this.subscribers.delete(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export class TextRenderer {
|
|||||||
);
|
);
|
||||||
this.cursorNode.setAttribute("x", "0");
|
this.cursorNode.setAttribute("x", "0");
|
||||||
this.cursorNode.setAttribute("y", "0");
|
this.cursorNode.setAttribute("y", "0");
|
||||||
|
this.cursorNode.setAttribute("class", "cursor");
|
||||||
this.svg.appendChild(this.cursorNode);
|
this.svg.appendChild(this.cursorNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||||
|
"highlight-action",
|
||||||
|
);
|
||||||
|
|
||||||
let {
|
let {
|
||||||
key,
|
key,
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
]}
|
]}
|
||||||
{@const hasIcon = !dynamicMapping && !!icon}
|
{@const hasIcon = !dynamicMapping && !!icon}
|
||||||
<text
|
<text
|
||||||
|
class:hidden={$currentAction?.has(actionId) === false}
|
||||||
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
||||||
font-weight={isApplied ? "" : "bold"}
|
font-weight={isApplied ? "" : "bold"}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
@@ -96,4 +100,8 @@
|
|||||||
text:focus-within {
|
text:focus-within {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text.hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,11 +8,14 @@
|
|||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
} from "svelte/elements";
|
} from "svelte/elements";
|
||||||
|
import { type Writable } from "svelte/store";
|
||||||
|
|
||||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||||
"visual-layout-config",
|
"visual-layout-config",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
|
||||||
|
|
||||||
let {
|
let {
|
||||||
i,
|
i,
|
||||||
key,
|
key,
|
||||||
@@ -35,6 +38,8 @@
|
|||||||
|
|
||||||
<g
|
<g
|
||||||
class="key-group"
|
class="key-group"
|
||||||
|
class:highlight={$highlight?.has(key.id) === true}
|
||||||
|
class:faded={$highlight?.has(key.id) === false}
|
||||||
{onclick}
|
{onclick}
|
||||||
{onkeypress}
|
{onkeypress}
|
||||||
{onfocusin}
|
{onfocusin}
|
||||||
@@ -131,12 +136,14 @@
|
|||||||
stroke-opacity: 0.3;
|
stroke-opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.faded,
|
||||||
g:hover {
|
g:hover {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity #{$transition} ease;
|
transition: opacity #{$transition} ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.highlight,
|
||||||
g:focus-within {
|
g:focus-within {
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -134,9 +134,9 @@ export class CharaDevice {
|
|||||||
await this.port.close();
|
await this.port.close();
|
||||||
|
|
||||||
this.version = new SemVer(
|
this.version = new SemVer(
|
||||||
await this.send(1, "VERSION").then(([version]) => version),
|
await this.send(1, ["VERSION"]).then(([version]) => version),
|
||||||
);
|
);
|
||||||
const [company, device, chipset] = await this.send(3, "ID");
|
const [company, device, chipset] = await this.send(3, ["ID"]);
|
||||||
this.company = company as typeof this.company;
|
this.company = company as typeof this.company;
|
||||||
this.device = device as typeof this.device;
|
this.device = device as typeof this.device;
|
||||||
this.chipset = chipset as typeof this.chipset;
|
this.chipset = chipset as typeof this.chipset;
|
||||||
@@ -185,9 +185,12 @@ export class CharaDevice {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async internalRead() {
|
private async internalRead(timeoutMs: number | undefined) {
|
||||||
try {
|
try {
|
||||||
const { value } = await timeout(this.reader.read(), 5000);
|
const { value } =
|
||||||
|
timeoutMs !== undefined
|
||||||
|
? await timeout(this.reader.read(), timeoutMs)
|
||||||
|
: await this.reader.read();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "output",
|
type: "output",
|
||||||
@@ -278,14 +281,15 @@ export class CharaDevice {
|
|||||||
*/
|
*/
|
||||||
async send<T extends number>(
|
async send<T extends number>(
|
||||||
expectedLength: T,
|
expectedLength: T,
|
||||||
...command: string[]
|
command: string[],
|
||||||
|
timeout: number | undefined = 5000,
|
||||||
): Promise<LengthArray<string, T>> {
|
): Promise<LengthArray<string, T>> {
|
||||||
return this.runWith(async (send, read) => {
|
return this.runWith(async (send, read) => {
|
||||||
await send(...command);
|
await send(...command);
|
||||||
const commandString = command
|
const commandString = command
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
const readResult = await read();
|
const readResult = await read(timeout);
|
||||||
if (readResult === undefined) {
|
if (readResult === undefined) {
|
||||||
console.error("No response");
|
console.error("No response");
|
||||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||||
@@ -307,7 +311,7 @@ export class CharaDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChordCount(): Promise<number> {
|
async getChordCount(): Promise<number> {
|
||||||
const [count] = await this.send(1, "CML C0");
|
const [count] = await this.send(1, ["CML", "C0"]);
|
||||||
return Number.parseInt(count);
|
return Number.parseInt(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +319,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a chord by index
|
* Retrieves a chord by index
|
||||||
*/
|
*/
|
||||||
async getChord(index: number | number[]): Promise<Chord> {
|
async getChord(index: number | number[]): Promise<Chord> {
|
||||||
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
|
const [actions, phrase] = await this.send(2, [
|
||||||
|
"CML",
|
||||||
|
"C1",
|
||||||
|
index.toString(),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
actions: parseChordActions(actions),
|
actions: parseChordActions(actions),
|
||||||
phrase: parsePhrase(phrase),
|
phrase: parsePhrase(phrase),
|
||||||
@@ -326,29 +334,30 @@ export class CharaDevice {
|
|||||||
* Retrieves the phrase for a set of actions
|
* Retrieves the phrase for a set of actions
|
||||||
*/
|
*/
|
||||||
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
||||||
const [phrase] = await this.send(
|
const [phrase] = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C2 ${stringifyChordActions(actions)}`,
|
"C2",
|
||||||
);
|
stringifyChordActions(actions),
|
||||||
|
]);
|
||||||
return phrase === "2" ? undefined : parsePhrase(phrase);
|
return phrase === "2" ? undefined : parsePhrase(phrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChord(chord: Chord) {
|
async setChord(chord: Chord) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
|
||||||
"CML",
|
"CML",
|
||||||
"C3",
|
"C3",
|
||||||
stringifyChordActions(chord.actions),
|
stringifyChordActions(chord.actions),
|
||||||
stringifyPhrase(chord.phrase),
|
stringifyPhrase(chord.phrase),
|
||||||
);
|
]);
|
||||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
if (status !== "0") console.error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||||
const status = await this.send(
|
const status = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C4 ${stringifyChordActions(chord.actions)}`,
|
"C4",
|
||||||
);
|
stringifyChordActions(chord.actions),
|
||||||
|
]);
|
||||||
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
||||||
throw new Error(`Failed with status ${status}`);
|
throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
@@ -360,7 +369,13 @@ export class CharaDevice {
|
|||||||
* @param action the assigned action id
|
* @param action the assigned action id
|
||||||
*/
|
*/
|
||||||
async setLayoutKey(layer: number, id: number, action: number) {
|
async setLayoutKey(layer: number, id: number, action: number) {
|
||||||
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
|
const [status] = await this.send(1, [
|
||||||
|
"VAR",
|
||||||
|
"B4",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
action.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +386,12 @@ export class CharaDevice {
|
|||||||
* @returns the assigned action id
|
* @returns the assigned action id
|
||||||
*/
|
*/
|
||||||
async getLayoutKey(layer: number, id: number) {
|
async getLayoutKey(layer: number, id: number) {
|
||||||
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
|
const [position, status] = await this.send(2, [
|
||||||
|
"VAR",
|
||||||
|
"B3",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
return Number(position);
|
return Number(position);
|
||||||
}
|
}
|
||||||
@@ -384,7 +404,7 @@ export class CharaDevice {
|
|||||||
* **This does not need to be called for chords**
|
* **This does not need to be called for chords**
|
||||||
*/
|
*/
|
||||||
async commit() {
|
async commit() {
|
||||||
const [status] = await this.send(1, "VAR B0");
|
const [status] = await this.send(1, ["VAR", "B0"]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,10 +415,12 @@ export class CharaDevice {
|
|||||||
* To permanently store the settings, you *must* call commit.
|
* To permanently store the settings, you *must* call commit.
|
||||||
*/
|
*/
|
||||||
async setSetting(id: number, value: number) {
|
async setSetting(id: number, value: number) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
"VAR",
|
||||||
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
|
"B2",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
value.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,10 +428,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a setting from the device
|
* Retrieves a setting from the device
|
||||||
*/
|
*/
|
||||||
async getSetting(id: number): Promise<number> {
|
async getSetting(id: number): Promise<number> {
|
||||||
const [value, status] = await this.send(
|
const [value, status] = await this.send(2, [
|
||||||
2,
|
"VAR",
|
||||||
`VAR B1 ${id.toString(16).toUpperCase()}`,
|
"B1",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
]);
|
||||||
if (status !== "0")
|
if (status !== "0")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
||||||
@@ -421,14 +444,14 @@ export class CharaDevice {
|
|||||||
* Reboots the device
|
* Reboots the device
|
||||||
*/
|
*/
|
||||||
async reboot() {
|
async reboot() {
|
||||||
await this.send(0, "RST");
|
await this.send(0, ["RST"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reboots the device to the bootloader
|
* Reboots the device to the bootloader
|
||||||
*/
|
*/
|
||||||
async bootloader() {
|
async bootloader() {
|
||||||
await this.send(0, "RST BOOTLOADER");
|
await this.send(0, ["RST", "BOOTLOADER"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,7 +460,12 @@ export class CharaDevice {
|
|||||||
async reset(
|
async reset(
|
||||||
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
||||||
) {
|
) {
|
||||||
await this.send(0, `RST ${type}`);
|
await this.send(0, ["RST", type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryKey(): Promise<number> {
|
||||||
|
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
|
||||||
|
return Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,7 +474,7 @@ export class CharaDevice {
|
|||||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||||
*/
|
*/
|
||||||
async getRamBytesAvailable(): Promise<number> {
|
async getRamBytesAvailable(): Promise<number> {
|
||||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFirmware(file: File | Blob): Promise<void> {
|
async updateFirmware(file: File | Blob): Promise<void> {
|
||||||
|
|||||||
15
src/lib/util/shuffle.ts
Normal file
15
src/lib/util/shuffle.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
|
||||||
|
*/
|
||||||
|
export function shuffleInPlace<T>(array: T[]) {
|
||||||
|
for (let i = array.length - 1; i >= 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffle<T>(array: T[]): T[] {
|
||||||
|
const result = [...array];
|
||||||
|
shuffleInPlace(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
$serialPort = undefined;
|
$serialPort = undefined;
|
||||||
try {
|
try {
|
||||||
const file = await fetch(
|
const file = await fetch(
|
||||||
`${data.meta.path}/${data.meta.update.ota!}`,
|
`${data.meta.path}/${data.meta.update.ota?.name}`,
|
||||||
).then((it) => it.blob());
|
).then((it) => it.blob());
|
||||||
|
|
||||||
await port.updateFirmware(file);
|
await port.updateFirmware(file);
|
||||||
|
|||||||
@@ -1,231 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
import {
|
|
||||||
words,
|
|
||||||
nextWord,
|
|
||||||
scores,
|
|
||||||
learnConfigDefault,
|
|
||||||
learnConfig,
|
|
||||||
learnConfigStored,
|
|
||||||
} from "$lib/learn/chords";
|
|
||||||
import { blur, fade } from "svelte/transition";
|
|
||||||
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
|
|
||||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
|
||||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
|
||||||
|
|
||||||
let recorder = $derived(new ReplayRecorder($nextWord));
|
|
||||||
let start = performance.now();
|
|
||||||
$effect(() => {
|
|
||||||
start = recorder && performance.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
let chords: InferredChord[] = $state([]);
|
|
||||||
|
|
||||||
function onkeyboard(event: KeyboardEvent) {
|
|
||||||
recorder.next(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number) {
|
|
||||||
return a + (b - a) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inspect(chords);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const [chord] = chords;
|
|
||||||
if (!chord) return;
|
|
||||||
|
|
||||||
console.log(chord);
|
|
||||||
|
|
||||||
if (chord.output.trim() === $nextWord) {
|
|
||||||
scores.update((scores) => {
|
|
||||||
const score = Math.max(
|
|
||||||
$learnConfig.minScore,
|
|
||||||
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!scores[$nextWord]) {
|
|
||||||
scores[$nextWord] = {
|
|
||||||
score,
|
|
||||||
lastTyped: performance.now(),
|
|
||||||
total: 1,
|
|
||||||
};
|
|
||||||
return scores;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldScore = scores[$nextWord].score;
|
|
||||||
scores[$nextWord].score = lerp(
|
|
||||||
score,
|
|
||||||
oldScore,
|
|
||||||
$learnConfig.scoreBlend,
|
|
||||||
);
|
|
||||||
scores[$nextWord].lastTyped = performance.now();
|
|
||||||
scores[$nextWord].total += 1;
|
|
||||||
|
|
||||||
return scores;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function skip() {
|
|
||||||
button?.blur();
|
|
||||||
scores.update((scores) => {
|
|
||||||
return scores;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let button = $state<HTMLButtonElement>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2>WIP</h2>
|
<ul>
|
||||||
|
<li><a href="/learn/layout/">Layout</a></li>
|
||||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
<li><a href="/learn/chords/">Chords</a></li>
|
||||||
|
<li><a href="/learn/sentence/">Sentences</a></li>
|
||||||
{#key $nextWord}
|
</ul>
|
||||||
<h3>
|
|
||||||
{$nextWord}
|
|
||||||
{#if $scores[$nextWord!] === undefined}
|
|
||||||
<sup class="new-word">new</sup>
|
|
||||||
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
|
||||||
<sup class="weak">weak</sup>
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="chord" in:fade>
|
|
||||||
<CharRecorder replay={recorder.player} cursor={true}>
|
|
||||||
<TrackChords bind:chords />
|
|
||||||
</CharRecorder>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
{#key $nextWord}
|
|
||||||
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
|
||||||
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
<button onclick={skip} bind:this={button}>skip</button>
|
|
||||||
|
|
||||||
<section class="stats">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Weak</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => a.score - b.score)
|
|
||||||
.splice(0, 10) as [word, score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
<td><i>{score.score.toFixed(2)}</i></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Strong</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => b.score - a.score)
|
|
||||||
.splice(0, 10) as [word, score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
<td><i>{score.score.toFixed(2)}</i></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Rehearse</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
|
||||||
.splice(0, 10) as [word, score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Settings</summary>
|
|
||||||
<button onclick={() => ($scores = {})}>Reset</button>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries(learnConfigDefault) as [key, value]}
|
|
||||||
<tr>
|
|
||||||
<th>{key}</th>
|
|
||||||
<td
|
|
||||||
><input
|
|
||||||
type="number"
|
|
||||||
value={$learnConfig[key] ?? value}
|
|
||||||
step="0.1"
|
|
||||||
oninput={(event) =>
|
|
||||||
($learnConfigStored[key] = event.target.value)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
disabled={!$learnConfigStored[key]}
|
|
||||||
onclick={() => ($learnConfigStored[key] = undefined)}>⟲</button
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "sass:math";
|
ul {
|
||||||
|
margin: 16px;
|
||||||
input {
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
border: none;
|
|
||||||
width: 5ch;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
min-width: 20ch;
|
|
||||||
padding: 1ch;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
align-items: center;
|
list-style-type: none;
|
||||||
justify-content: center;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
a {
|
||||||
display: flex;
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
gap: 3em;
|
width: 128px;
|
||||||
}
|
height: 128px;
|
||||||
|
|
||||||
sup {
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: 0.8em;
|
|
||||||
|
|
||||||
&.new-word {
|
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
&.weak {
|
|
||||||
color: var(--md-sys-color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@for $i from 1 through 10 {
|
|
||||||
tr.decay:nth-child(#{$i}) {
|
|
||||||
opacity: 1 - math.div($i, 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
231
src/routes/(app)/learn/chords/+page.svelte
Normal file
231
src/routes/(app)/learn/chords/+page.svelte
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||||
|
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
import {
|
||||||
|
words,
|
||||||
|
nextWord,
|
||||||
|
scores,
|
||||||
|
learnConfigDefault,
|
||||||
|
learnConfig,
|
||||||
|
learnConfigStored,
|
||||||
|
} from "$lib/learn/chords";
|
||||||
|
import { blur, fade } from "svelte/transition";
|
||||||
|
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
|
||||||
|
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||||
|
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||||
|
|
||||||
|
let recorder = $derived(new ReplayRecorder($nextWord));
|
||||||
|
let start = performance.now();
|
||||||
|
$effect(() => {
|
||||||
|
start = recorder && performance.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chords: InferredChord[] = $state([]);
|
||||||
|
|
||||||
|
function onkeyboard(event: KeyboardEvent) {
|
||||||
|
recorder.next(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspect(chords);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const [chord] = chords;
|
||||||
|
if (!chord) return;
|
||||||
|
|
||||||
|
console.log(chord);
|
||||||
|
|
||||||
|
if (chord.output.trim() === $nextWord) {
|
||||||
|
scores.update((scores) => {
|
||||||
|
const score = Math.max(
|
||||||
|
$learnConfig.minScore,
|
||||||
|
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!scores[$nextWord]) {
|
||||||
|
scores[$nextWord] = {
|
||||||
|
score,
|
||||||
|
lastTyped: performance.now(),
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldScore = scores[$nextWord].score;
|
||||||
|
scores[$nextWord].score = lerp(
|
||||||
|
score,
|
||||||
|
oldScore,
|
||||||
|
$learnConfig.scoreBlend,
|
||||||
|
);
|
||||||
|
scores[$nextWord].lastTyped = performance.now();
|
||||||
|
scores[$nextWord].total += 1;
|
||||||
|
|
||||||
|
return scores;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
button?.blur();
|
||||||
|
scores.update((scores) => {
|
||||||
|
return scores;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = $state<HTMLButtonElement>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2>WIP</h2>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
||||||
|
|
||||||
|
{#key $nextWord}
|
||||||
|
<h3>
|
||||||
|
{$nextWord}
|
||||||
|
{#if $scores[$nextWord!] === undefined}
|
||||||
|
<sup class="new-word">new</sup>
|
||||||
|
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
||||||
|
<sup class="weak">weak</sup>
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="chord" in:fade>
|
||||||
|
<CharRecorder replay={recorder.player} cursor={true}>
|
||||||
|
<TrackChords bind:chords />
|
||||||
|
</CharRecorder>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#key $nextWord}
|
||||||
|
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
||||||
|
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<button onclick={skip} bind:this={button}>skip</button>
|
||||||
|
|
||||||
|
<section class="stats">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Weak</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => a.score - b.score)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
<td><i>{score.score.toFixed(2)}</i></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Strong</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => b.score - a.score)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
<td><i>{score.score.toFixed(2)}</i></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rehearse</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Settings</summary>
|
||||||
|
<button onclick={() => ($scores = {})}>Reset</button>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries(learnConfigDefault) as [key, value]}
|
||||||
|
<tr>
|
||||||
|
<th>{key}</th>
|
||||||
|
<td
|
||||||
|
><input
|
||||||
|
type="number"
|
||||||
|
value={$learnConfig[key] ?? value}
|
||||||
|
step="0.1"
|
||||||
|
oninput={(event) =>
|
||||||
|
($learnConfigStored[key] = event.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
disabled={!$learnConfigStored[key]}
|
||||||
|
onclick={() => ($learnConfigStored[key] = undefined)}>⟲</button
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:math";
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
width: 5ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
min-width: 20ch;
|
||||||
|
padding: 1ch;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8em;
|
||||||
|
|
||||||
|
&.new-word {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
&.weak {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through 10 {
|
||||||
|
tr.decay:nth-child(#{$i}) {
|
||||||
|
opacity: 1 - math.div($i, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
src/routes/(app)/learn/layout/+page.svelte
Normal file
132
src/routes/(app)/learn/layout/+page.svelte
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { share } from "$lib/share";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
import { mount, setContext, unmount } from "svelte";
|
||||||
|
import Layout from "$lib/components/layout/Layout.svelte";
|
||||||
|
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||||
|
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import { layout } from "$lib/undo-redo";
|
||||||
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import { serialPort } from "$lib/serial/connection";
|
||||||
|
|
||||||
|
let hasStarted = $state(false);
|
||||||
|
|
||||||
|
setContext<VisualLayoutConfig>("visual-layout-config", {
|
||||||
|
scale: 50,
|
||||||
|
inactiveScale: 0.5,
|
||||||
|
inactiveOpacity: 0.4,
|
||||||
|
strokeWidth: 1,
|
||||||
|
margin: 5,
|
||||||
|
fontSize: 9,
|
||||||
|
iconFontSize: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = derived(layout, (layout) => {
|
||||||
|
const result = new Set<number>();
|
||||||
|
for (const layer of layout) {
|
||||||
|
for (const key of layer) {
|
||||||
|
result.add(key.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...result];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAction = writable(0);
|
||||||
|
|
||||||
|
const expected = derived(
|
||||||
|
[layout, currentAction],
|
||||||
|
([layout, currentAction]) => {
|
||||||
|
const result: Array<{ layer: number; key: number }> = [];
|
||||||
|
for (let layer = 0; layer <= layout.length; layer++) {
|
||||||
|
if (layout[layer] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let key = 0; key <= layout[layer].length; key++) {
|
||||||
|
if (layout[layer][key]?.action === currentAction) {
|
||||||
|
result.push({ layer, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlight = derived(
|
||||||
|
expected,
|
||||||
|
(expected) => new Set(expected.map(({ key }) => key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightAction = derived(
|
||||||
|
currentAction,
|
||||||
|
(currentAction) => new Set([currentAction]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLayer = writable(0);
|
||||||
|
|
||||||
|
setContext("highlight", highlight);
|
||||||
|
|
||||||
|
setContext("highlight-action", highlightAction);
|
||||||
|
|
||||||
|
setContext("active-layer", currentLayer);
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
console.log("Next");
|
||||||
|
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
|
||||||
|
if (nextAction !== undefined) {
|
||||||
|
currentAction.set(nextAction);
|
||||||
|
currentLayer.set($expected[0]?.layer ?? 0);
|
||||||
|
const key = await $serialPort?.queryKey();
|
||||||
|
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
|
||||||
|
console.log("Correct", key);
|
||||||
|
} else {
|
||||||
|
console.log("Incorrect", key);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
|
||||||
|
hasStarted = true;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="challenge">
|
||||||
|
<Action display="inline-keys" action={$currentAction}></Action>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Layout />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.challenge {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
580
src/routes/(app)/learn/sentence/+page.svelte
Normal file
580
src/routes/(app)/learn/sentence/+page.svelte
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||||
|
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
import { shuffleInPlace } from "$lib/util/shuffle";
|
||||||
|
import { fade, fly, slide } from "svelte/transition";
|
||||||
|
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||||
|
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
|
||||||
|
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import TrackText from "$lib/charrecorder/TrackText.svelte";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
|
|
||||||
|
function viaLocalStorage<T>(key: string, initial: T) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(key) ?? "");
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
||||||
|
viaLocalStorage("mastery-thresholds", [
|
||||||
|
[1500, 1050, "Words"],
|
||||||
|
[3000, 2500, "Pairs"],
|
||||||
|
[5000, 3500, "Trios"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
let wpmTarget = $derived(
|
||||||
|
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
|
||||||
|
);
|
||||||
|
let devTools = $derived(
|
||||||
|
browser && $page.url.searchParams.get("dev") === "true",
|
||||||
|
);
|
||||||
|
let sentenceWords = $derived(inputSentence.split(" "));
|
||||||
|
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
|
||||||
|
let totalMs = $derived(inputSentence.length * msPerChar);
|
||||||
|
let msPerWord = $derived(
|
||||||
|
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
|
||||||
|
);
|
||||||
|
let currentWord = $state("");
|
||||||
|
let wordStats = new SvelteMap<string, number[]>();
|
||||||
|
let wordMastery = new SvelteMap<string, number>();
|
||||||
|
let text = $state("");
|
||||||
|
let level = $state(0);
|
||||||
|
let bestWPM = $state(0);
|
||||||
|
let wpm = $state(0);
|
||||||
|
let chords: InferredChord[] = $state([]);
|
||||||
|
let recorder = $state(new ReplayRecorder());
|
||||||
|
let idle = $state(true);
|
||||||
|
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
|
||||||
|
|
||||||
|
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
let cooldown = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
selectNextWord();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (wpm > bestWPM) {
|
||||||
|
bestWPM = wpm;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem("idle-timeout", idleTime.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"mastery-thresholds",
|
||||||
|
JSON.stringify(masteryThresholds),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let words = $derived.by(() => {
|
||||||
|
const words = inputSentence.trim().split(" ");
|
||||||
|
switch (level) {
|
||||||
|
case 0: {
|
||||||
|
shuffleInPlace(words);
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
const pairs = [];
|
||||||
|
for (let i = 0; i < words.length - 1; i++) {
|
||||||
|
pairs.push(`${words[i]} ${words[i + 1]}`);
|
||||||
|
}
|
||||||
|
shuffleInPlace(pairs);
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
const trios = [];
|
||||||
|
for (let i = 0; i < words.length - 2; i++) {
|
||||||
|
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
||||||
|
}
|
||||||
|
shuffleInPlace(trios);
|
||||||
|
return trios;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return [inputSentence];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const [word, speeds] of wordStats.entries()) {
|
||||||
|
const level = word.split(" ").length - 1;
|
||||||
|
const masteryThreshold = masteryThresholds[level];
|
||||||
|
if (masteryThreshold === undefined) continue;
|
||||||
|
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
|
||||||
|
wordMastery.set(
|
||||||
|
word,
|
||||||
|
1 -
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
(averageSpeed - masteryThreshold[1]) /
|
||||||
|
(masteryThreshold[0] - masteryThreshold[1]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let progress = $derived(
|
||||||
|
level === masteryThresholds.length
|
||||||
|
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
|
||||||
|
: words.length > 0
|
||||||
|
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
|
||||||
|
words.length
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
let mastered = $derived(
|
||||||
|
words.length > 0
|
||||||
|
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (progress === 1 && level < masteryThresholds.length) {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
currentWord = nextWord;
|
||||||
|
recorder = new ReplayRecorder(nextWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInput() {
|
||||||
|
if (recorder.player.stepper.challenge.length === 0) return;
|
||||||
|
const replay = recorder.finish(false);
|
||||||
|
const elapsed = replay.finish - replay.start! - idleTime;
|
||||||
|
if (elapsed < masteryThresholds[level]![0]) {
|
||||||
|
const prevStats = wordStats.get(currentWord) ?? [];
|
||||||
|
prevStats.push(elapsed);
|
||||||
|
wordStats.set(currentWord, prevStats.slice(-10));
|
||||||
|
}
|
||||||
|
|
||||||
|
text = "";
|
||||||
|
setTimeout(() => {
|
||||||
|
selectNextWord();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!idle || !text) return;
|
||||||
|
if (text.trim() !== currentWord.trim()) return;
|
||||||
|
if (level === masteryThresholds.length) {
|
||||||
|
const replay = recorder.finish();
|
||||||
|
const elapsed = replay.finish - replay.start!;
|
||||||
|
text = "";
|
||||||
|
recorder = new ReplayRecorder(currentWord);
|
||||||
|
console.log(elapsed, totalMs);
|
||||||
|
wpm = (totalMs / elapsed) * wpmTarget;
|
||||||
|
} else {
|
||||||
|
checkInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onkey(event: KeyboardEvent) {
|
||||||
|
if (idleTimeout) {
|
||||||
|
clearTimeout(idleTimeout);
|
||||||
|
}
|
||||||
|
idle = false;
|
||||||
|
recorder.next(event);
|
||||||
|
idleTimeout = setTimeout(() => {
|
||||||
|
idle = true;
|
||||||
|
}, idleTime);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Sentence Trainer</h1>
|
||||||
|
|
||||||
|
<div class="levels">
|
||||||
|
{#each masteryThresholds as [, , title], i}
|
||||||
|
<button
|
||||||
|
class:active={level === i}
|
||||||
|
class:mastered={i < level || progress === 1}
|
||||||
|
class="threshold"
|
||||||
|
onclick={() => {
|
||||||
|
level = i;
|
||||||
|
selectNextWord();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
class:active={level === masteryThresholds.length}
|
||||||
|
class:mastered={masteryThresholds.length < level || progress === 1}
|
||||||
|
class="threshold"
|
||||||
|
onclick={() => {
|
||||||
|
level = masteryThresholds.length;
|
||||||
|
selectNextWord();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wpmTarget} WPM
|
||||||
|
</button>
|
||||||
|
{#each masteryThresholds as _, i}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style:--progress="{-100 *
|
||||||
|
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
|
||||||
|
style:--mastered="{-100 *
|
||||||
|
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
|
||||||
|
class:active={level === i}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style:--progress="-100%"
|
||||||
|
style:--mastered="{-100 *
|
||||||
|
(1 -
|
||||||
|
(level === masteryThresholds.length
|
||||||
|
? progress
|
||||||
|
: masteryThresholds.length < level
|
||||||
|
? 1
|
||||||
|
: 0))}%"
|
||||||
|
class:active={level === masteryThresholds.length}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="sentence">
|
||||||
|
{#each sentenceWords as _, i}
|
||||||
|
{#if i !== sentenceWords.length - 1}
|
||||||
|
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<div
|
||||||
|
class="arch"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery}
|
||||||
|
style:grid-row={(i % 2) + 1}
|
||||||
|
style:grid-column="{i + 1} / span 2"
|
||||||
|
style:border-bottom="none"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each sentenceWords as word, i}
|
||||||
|
{@const mastery = wordMastery.get(word)}
|
||||||
|
<div
|
||||||
|
class="word"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery ?? 0}
|
||||||
|
style:grid-row={3}
|
||||||
|
style:grid-column={i + 1}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each sentenceWords as _, i}
|
||||||
|
{#if i < sentenceWords.length - 2}
|
||||||
|
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<div
|
||||||
|
class="arch"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery}
|
||||||
|
style:grid-row={(i % 3) + 4}
|
||||||
|
style:grid-column="{i + 1} / span 3"
|
||||||
|
style:border-top="none"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if level === masteryThresholds.length}
|
||||||
|
{@const maxDigits = 4}
|
||||||
|
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
|
||||||
|
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
|
||||||
|
<div class="finish" transition:slide>
|
||||||
|
<div
|
||||||
|
class="wpm"
|
||||||
|
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
|
||||||
|
style:opacity={progress}
|
||||||
|
style:font-size="3rem"
|
||||||
|
style:color="var(--md-sys-color-{progress === 1
|
||||||
|
? 'primary'
|
||||||
|
: 'on-background'})"
|
||||||
|
style:scale={(progress + 0.5) / 2}
|
||||||
|
>
|
||||||
|
{#each indices as i}
|
||||||
|
{@const char = wpmString[i]}
|
||||||
|
{#key char}
|
||||||
|
<div
|
||||||
|
style:grid-column={i + 1}
|
||||||
|
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
|
||||||
|
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
<div style:grid-column={maxDigits + 3} style:justify-self="start">
|
||||||
|
WPM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="wpm"
|
||||||
|
style:grid-template-columns="4ch 1ch auto"
|
||||||
|
style:font-size="1.5rem"
|
||||||
|
>
|
||||||
|
{#key wpm}
|
||||||
|
<div
|
||||||
|
style:grid-column={1}
|
||||||
|
style:justify-self="end"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
{Math.floor(wpm)}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<div style:grid-column={3} style:justify-self="start">WPM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ChordHud {chords} />
|
||||||
|
<div class="container">
|
||||||
|
<div
|
||||||
|
class="input-section"
|
||||||
|
onkeydown={onkey}
|
||||||
|
onkeyup={onkey}
|
||||||
|
tabindex="0"
|
||||||
|
role="textbox"
|
||||||
|
>
|
||||||
|
{#key recorder}
|
||||||
|
<div class="input" transition:fade={{ duration: 200 }}>
|
||||||
|
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||||
|
<TrackText bind:text />
|
||||||
|
<TrackChords bind:chords />
|
||||||
|
</CharRecorder>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if devTools}
|
||||||
|
<div>Dev Tools</div>
|
||||||
|
<button onclick={reset}>Reset</button>
|
||||||
|
<label>Idle Time <input bind:value={idleTime} /></label>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Total</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(totalMs)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Char</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(msPerChar)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Word</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(msPerWord)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each masteryThresholds as _, i}
|
||||||
|
<tr>
|
||||||
|
<th>L{i + 1}</th>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![0]} /></td>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![1]} /></td>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![2]} /></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each wordStats.entries() as [word, stats]}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<tr>
|
||||||
|
<th>{word}</th>
|
||||||
|
<td
|
||||||
|
style:color="var(--md-sys-color-{mastery === 1
|
||||||
|
? 'primary'
|
||||||
|
: 'tertiary'})">{Math.round(mastery * 100)}%</td
|
||||||
|
>
|
||||||
|
{#each stats as stat}
|
||||||
|
<td>{stat}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.levels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wpm {
|
||||||
|
width: min-content;
|
||||||
|
display: grid;
|
||||||
|
transition: scale 0.2s ease;
|
||||||
|
|
||||||
|
* {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finish {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
font-weight: bold;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentence {
|
||||||
|
display: grid;
|
||||||
|
width: min-content;
|
||||||
|
gap: 4px 1ch;
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
margin-block: 1rem;
|
||||||
|
|
||||||
|
.word,
|
||||||
|
.arch {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&.mastered {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arch {
|
||||||
|
border: 2px solid var(--md-sys-color-outline);
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: relative;
|
||||||
|
height: 1rem;
|
||||||
|
width: auto;
|
||||||
|
background: var(--md-sys-color-outline-variant);
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-row: 2;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: var(--md-sys-color-outline);
|
||||||
|
transform: translateX(var(--progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
transform: translateX(var(--mastered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold {
|
||||||
|
width: auto;
|
||||||
|
justify-self: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
grid-row: 1;
|
||||||
|
|
||||||
|
&.mastered,
|
||||||
|
&.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mastered {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section {
|
||||||
|
display: grid;
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
:global(.cursor) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 16cm;
|
||||||
|
outline: 2px dashed transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-block: 1rem;
|
||||||
|
transition:
|
||||||
|
outline 0.2s ease,
|
||||||
|
border-radius 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section:focus-within {
|
||||||
|
outline: none;
|
||||||
|
.input {
|
||||||
|
outline-color: var(--md-sys-color-primary);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cursor) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user