mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-08 10:12:39 +00:00
Compare commits
16 Commits
7f27499003
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
bd1c6147fd
|
|||
|
891abda0fb
|
|||
|
3611f65e24
|
|||
|
f76882a09c
|
|||
|
ff7e4f7b2e
|
|||
|
1c1c86241f
|
|||
|
dc8b3c3d66
|
|||
|
|
65911419b0 | ||
|
|
ccfb09e261 | ||
|
b841469505
|
|||
|
bc06e8ee80
|
|||
|
24fc861ef4
|
|||
|
5801e5fbbe
|
|||
|
92b52e08f7
|
|||
|
4192210d27
|
|||
|
|
0e5640a1ee |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ node_modules
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
|
/openssl*
|
||||||
|
/src/i18n/i18n*
|
||||||
|
|
||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ To generate the icons use the following command:
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm run minify-icons
|
npm run minify-icons
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ const config = {
|
|||||||
"ring_volume",
|
"ring_volume",
|
||||||
"wifi",
|
"wifi",
|
||||||
"power_settings_circle",
|
"power_settings_circle",
|
||||||
"audio",
|
"graphic_eq",
|
||||||
"mail",
|
"mail",
|
||||||
"calculator",
|
"calculate",
|
||||||
"open_in_browser",
|
"open_in_browser",
|
||||||
"chevron_backward",
|
"chevron_backward",
|
||||||
"chevron_forward",
|
"chevron_forward",
|
||||||
@@ -80,6 +80,9 @@ const config = {
|
|||||||
"delete",
|
"delete",
|
||||||
"remove_selection",
|
"remove_selection",
|
||||||
"bolt",
|
"bolt",
|
||||||
|
"thunderstorm",
|
||||||
|
"join_inner",
|
||||||
|
"uppercase",
|
||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
"replay",
|
"replay",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "2.2.3",
|
"version": "2.3.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "2.2.3"
|
version = "2.3.0"
|
||||||
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.3" },
|
"package": { "productName": "amacc1ng", "version": "2.3.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { expoIn, expoOut } from "svelte/easing";
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
|
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
let inDirection = $state(0);
|
let inDirection = $state(0);
|
||||||
|
|||||||
@@ -177,7 +177,6 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
$border-radius: 16px;
|
$border-radius: 16px;
|
||||||
|
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@@ -220,7 +219,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
section {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -84,7 +84,6 @@
|
|||||||
border-right-width: 3px;
|
border-right-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
margin-inline-end: 2px;
|
margin-inline-end: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,9 @@
|
|||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
|
unicode-range:
|
||||||
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
|
||||||
|
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||||
U+A720-A7FF;
|
U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,8 @@
|
|||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
unicode-range:
|
||||||
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
|
||||||
|
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function getMeta(
|
|||||||
try {
|
try {
|
||||||
if (!browser) return fetchMeta(device, version, fetch);
|
if (!browser) return fetchMeta(device, version, fetch);
|
||||||
|
|
||||||
const dbRequest = indexedDB.open("version-meta", 3);
|
const dbRequest = indexedDB.open("version-meta", 4);
|
||||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||||
dbRequest.onerror = () => reject(dbRequest.error);
|
dbRequest.onerror = () => reject(dbRequest.error);
|
||||||
@@ -120,6 +120,9 @@ async function fetchMeta(
|
|||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
})),
|
})),
|
||||||
|
changelog: await (meta?.changelog
|
||||||
|
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
|
||||||
|
: {}),
|
||||||
actions: await (meta?.actions
|
actions: await (meta?.actions
|
||||||
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
||||||
: Promise.all<KeymapCategory[]>(
|
: Promise.all<KeymapCategory[]>(
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export interface SettingsItemMeta {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangelogEntry {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changelog {
|
||||||
|
features: ChangelogEntry[];
|
||||||
|
fixes: ChangelogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RawVersionMeta {
|
export interface RawVersionMeta {
|
||||||
version: string;
|
version: string;
|
||||||
target: string;
|
target: string;
|
||||||
@@ -32,6 +42,7 @@ export interface RawVersionMeta {
|
|||||||
development_mode: number;
|
development_mode: number;
|
||||||
actions: string;
|
actions: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
changelog: string;
|
||||||
factory_defaults: {
|
factory_defaults: {
|
||||||
layout: string;
|
layout: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
@@ -57,6 +68,7 @@ export interface VersionMeta {
|
|||||||
developmentBuild: boolean;
|
developmentBuild: boolean;
|
||||||
actions: KeymapCategory[];
|
actions: KeymapCategory[];
|
||||||
settings: SettingsMeta[];
|
settings: SettingsMeta[];
|
||||||
|
changelog: Changelog;
|
||||||
factoryDefaults?: {
|
factoryDefaults?: {
|
||||||
layout: CharaLayoutFile;
|
layout: CharaLayoutFile;
|
||||||
settings: CharaSettingsFile;
|
settings: CharaSettingsFile;
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialo
|
|||||||
|
|
||||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
|
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
|
||||||
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
|
||||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
|
||||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
|
||||||
|
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const KEY_COUNTS = {
|
const KEY_COUNTS = {
|
||||||
@@ -477,10 +478,14 @@ export class CharaDevice {
|
|||||||
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: ArrayBuffer,
|
||||||
|
progress: (transferred: number, total: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
while (this.lock) {
|
while (this.lock) {
|
||||||
await this.lock;
|
await this.lock;
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolveLock: (result: true) => void;
|
let resolveLock: (result: true) => void;
|
||||||
this.lock = new Promise<true>((resolve) => {
|
this.lock = new Promise<true>((resolve) => {
|
||||||
resolveLock = resolve;
|
resolveLock = resolve;
|
||||||
@@ -510,46 +515,46 @@ export class CharaDevice {
|
|||||||
});
|
});
|
||||||
return it;
|
return it;
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
writer.releaseLock();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the device to be ready
|
// Wait for the device to be ready
|
||||||
const signal = await this.reader.read();
|
const signal = await this.reader.read();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "output",
|
type: "output",
|
||||||
value: signal.value!.trim(),
|
value: signal.value!.trim(),
|
||||||
|
});
|
||||||
|
return it;
|
||||||
});
|
});
|
||||||
return it;
|
|
||||||
});
|
|
||||||
|
|
||||||
await file.stream().pipeTo(this.port.writable!);
|
const chunkSize = 128;
|
||||||
|
for (let i = 0; i < file.byteLength; i += chunkSize) {
|
||||||
|
const chunk = file.slice(i, i + chunkSize);
|
||||||
|
await writer.write(new Uint8Array(chunk));
|
||||||
|
progress(i + chunk.byteLength, file.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "input",
|
type: "input",
|
||||||
value: `...${file.size} bytes`,
|
value: `...${file.byteLength} bytes`,
|
||||||
|
});
|
||||||
|
return it;
|
||||||
});
|
});
|
||||||
return it;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = (await this.reader.read()).value!.trim();
|
const result = (await this.reader.read()).value!.trim();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "output",
|
type: "output",
|
||||||
value: result!,
|
value: result!,
|
||||||
|
});
|
||||||
|
return it;
|
||||||
});
|
});
|
||||||
return it;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result !== "OTA OK") {
|
if (result !== "OTA OK") {
|
||||||
throw new Error(result);
|
throw new Error(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
const writer2 = this.port.writable!.getWriter();
|
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||||
try {
|
|
||||||
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "input",
|
type: "input",
|
||||||
@@ -558,7 +563,7 @@ export class CharaDevice {
|
|||||||
return it;
|
return it;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
writer2.releaseLock();
|
writer.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.suspend();
|
await this.suspend();
|
||||||
|
|||||||
@@ -1,6 +1,85 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://gist.github.com/mjackson/5311256
|
||||||
|
*/
|
||||||
|
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
const v = max;
|
||||||
|
|
||||||
|
const d = max - min;
|
||||||
|
const s = max == 0 ? 0 : d / max;
|
||||||
|
|
||||||
|
if (max == min) {
|
||||||
|
h = 0; // achromatic
|
||||||
|
} else {
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Math.floor(h * 0xffff), Math.floor(s * 0xff), Math.floor(v * 0xff)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://gist.github.com/mjackson/5311256
|
||||||
|
*/
|
||||||
|
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||||
|
h /= 0xffff;
|
||||||
|
s /= 0xff;
|
||||||
|
v /= 0xff;
|
||||||
|
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
|
||||||
|
const i = Math.floor(h * 6);
|
||||||
|
const f = h * 6 - i;
|
||||||
|
const p = v * (1 - s);
|
||||||
|
const q = v * (1 - f * s);
|
||||||
|
const t = v * (1 - (1 - f) * s);
|
||||||
|
|
||||||
|
switch (i % 6) {
|
||||||
|
case 0:
|
||||||
|
(r = v), (g = t), (b = p);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
(r = q), (g = v), (b = p);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
(r = p), (g = v), (b = t);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
(r = p), (g = q), (b = v);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
(r = t), (g = p), (b = v);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
(r = v), (g = p), (b = q);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Math.floor(r * 0xff), Math.floor(g * 0xff), Math.floor(b * 0xff)];
|
||||||
|
}
|
||||||
|
|
||||||
export const setting: Action<
|
export const setting: Action<
|
||||||
HTMLInputElement | HTMLSelectElement,
|
HTMLInputElement | HTMLSelectElement,
|
||||||
{ id: number; inverse?: number; scale?: number }
|
{ id: number; inverse?: number; scale?: number }
|
||||||
@@ -9,7 +88,12 @@ export const setting: Action<
|
|||||||
{ id, inverse, scale },
|
{ id, inverse, scale },
|
||||||
) {
|
) {
|
||||||
node.setAttribute("disabled", "");
|
node.setAttribute("disabled", "");
|
||||||
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
|
const type = node.getAttribute("type") as
|
||||||
|
| "number"
|
||||||
|
| "checkbox"
|
||||||
|
| "range"
|
||||||
|
| "color";
|
||||||
|
const isColor = type === "color";
|
||||||
const isNumeric =
|
const isNumeric =
|
||||||
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
||||||
const min = node.hasAttribute("min")
|
const min = node.hasAttribute("min")
|
||||||
@@ -30,6 +114,13 @@ export const setting: Action<
|
|||||||
? scale * value
|
? scale * value
|
||||||
: value
|
: value
|
||||||
).toString();
|
).toString();
|
||||||
|
} else if (isColor) {
|
||||||
|
const rgb = hsvToRgb(
|
||||||
|
settings[id]!.value,
|
||||||
|
settings[id + 1]!.value,
|
||||||
|
settings[id + 2]!.value,
|
||||||
|
);
|
||||||
|
node.value = `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
||||||
} else {
|
} else {
|
||||||
node.checked = value !== 0;
|
node.checked = value !== 0;
|
||||||
}
|
}
|
||||||
@@ -49,6 +140,8 @@ export const setting: Action<
|
|||||||
if (isNumeric) {
|
if (isNumeric) {
|
||||||
value = Number(node.value);
|
value = Number(node.value);
|
||||||
if (Number.isNaN(value)) return;
|
if (Number.isNaN(value)) return;
|
||||||
|
if (min !== undefined) value = Math.max(min, value);
|
||||||
|
if (max !== undefined) value = Math.min(max, value);
|
||||||
value = Math.floor(
|
value = Math.floor(
|
||||||
inverse !== undefined
|
inverse !== undefined
|
||||||
? inverse / value
|
? inverse / value
|
||||||
@@ -56,8 +149,22 @@ export const setting: Action<
|
|||||||
? value / scale
|
? value / scale
|
||||||
: value,
|
: value,
|
||||||
);
|
);
|
||||||
if (min !== undefined) value = Math.max(min, value);
|
} else if (isColor) {
|
||||||
if (max !== undefined) value = Math.min(max, value);
|
const r = parseInt(node.value.slice(1, 3), 16);
|
||||||
|
const g = parseInt(node.value.slice(3, 5), 16);
|
||||||
|
const b = parseInt(node.value.slice(5, 7), 16);
|
||||||
|
const hsv = rgbToHsv(r, g, b);
|
||||||
|
changes.update((changes) => {
|
||||||
|
changes.push(
|
||||||
|
hsv.map((value, i) => ({
|
||||||
|
type: ChangeType.Setting,
|
||||||
|
id: id + i,
|
||||||
|
setting: value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
value = node.checked ? 1 : 0;
|
value = node.checked ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/lib/util/debounce.ts
Normal file
47
src/lib/util/debounce.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -173,7 +173,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
||||||
.sync-box {
|
.sync-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -54,5 +54,6 @@
|
|||||||
|
|
||||||
progress::-webkit-progress-value {
|
progress::-webkit-progress-value {
|
||||||
background: var(--md-sys-color-primary);
|
background: var(--md-sys-color-primary);
|
||||||
|
transition: width 2s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
let unsafeUpdate = $state(false);
|
let unsafeUpdate = $state(false);
|
||||||
|
|
||||||
let terminalOutput = $state("");
|
let terminalOutput = $state("");
|
||||||
|
let progress = $state(0);
|
||||||
|
|
||||||
let step = $state(0);
|
let step = $state(0);
|
||||||
let eraseAll = $state(false);
|
let eraseAll = $state(false);
|
||||||
@@ -28,9 +29,11 @@
|
|||||||
try {
|
try {
|
||||||
const file = await fetch(
|
const file = await fetch(
|
||||||
`${data.meta.path}/${data.meta.update.ota}`,
|
`${data.meta.path}/${data.meta.update.ota}`,
|
||||||
).then((it) => it.blob());
|
).then((it) => it.arrayBuffer());
|
||||||
|
|
||||||
await port.updateFirmware(file);
|
await port.updateFirmware(file, (transferred, total) => {
|
||||||
|
progress = transferred / total;
|
||||||
|
});
|
||||||
|
|
||||||
success = true;
|
success = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -194,7 +197,9 @@
|
|||||||
<section>
|
<section>
|
||||||
<button
|
<button
|
||||||
class="update-button"
|
class="update-button"
|
||||||
class:working
|
class:working={working && (progress <= 0 || progress >= 1)}
|
||||||
|
class:progress={working && progress > 0 && progress < 1}
|
||||||
|
style:--progress="{progress * 100}%"
|
||||||
class:primary={!buttonError}
|
class:primary={!buttonError}
|
||||||
class:error={buttonError}
|
class:error={buttonError}
|
||||||
disabled={working || $serialPort === undefined || !isCorrectDevice}
|
disabled={working || $serialPort === undefined || !isCorrectDevice}
|
||||||
@@ -282,7 +287,7 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if data.meta.update.esptool}
|
{#if false && data.meta.update.esptool}
|
||||||
<section>
|
<section>
|
||||||
<h3>Factory Flash (WIP)</h3>
|
<h3>Factory Flash (WIP)</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -314,11 +319,56 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="changelog">
|
||||||
|
<h2>Changelog</h2>
|
||||||
|
{#if data.meta.changelog.features}
|
||||||
|
<h3>Features</h3>
|
||||||
|
<ul>
|
||||||
|
{#each data.meta.changelog.features as feature}
|
||||||
|
<li>
|
||||||
|
<b>{@html feature.summary}</b>
|
||||||
|
{@html feature.description}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if data.meta.changelog.fixes}
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
{#each data.meta.changelog.fixes as fix}
|
||||||
|
<li>
|
||||||
|
<b>{@html fix.summary}</b>
|
||||||
|
{@html fix.description}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
h3 {
|
.changelog:empty {
|
||||||
margin-block-start: 4em;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-inline-start: 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog li {
|
||||||
|
margin-block: 0.2em;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog b {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--md-sys-color-on-tertiary-container);
|
||||||
|
background: var(--md-sys-color-tertiary-container);
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
border-radius: 8px;
|
||||||
|
translate: -0.5em -0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
@@ -422,6 +472,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.progress,
|
||||||
&.working {
|
&.working {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
@@ -445,15 +496,23 @@
|
|||||||
height: 30%;
|
height: 30%;
|
||||||
width: 120%;
|
width: 120%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
&.progress::after {
|
||||||
|
z-index: -2;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
content: "";
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
opacity: 0.2;
|
||||||
|
height: 100%;
|
||||||
|
width: var(--progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
color: var(--md-sys-color-secondary);
|
color: var(--md-sys-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.incorrect-device {
|
.incorrect-device {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,22 @@
|
|||||||
if (!port) return;
|
if (!port) return;
|
||||||
$syncStatus = "uploading";
|
$syncStatus = "uploading";
|
||||||
|
|
||||||
|
const layoutChanges = $overlay.layout.reduce(
|
||||||
|
(acc, layer) => acc + layer.size,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const settingChanges = $overlay.settings.size;
|
||||||
|
const chordChanges = $overlay.chords.size;
|
||||||
|
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||||
|
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||||
|
|
||||||
|
let progressCurrent = 0;
|
||||||
|
|
||||||
|
syncProgress.set({
|
||||||
|
max: progressMax,
|
||||||
|
current: progressCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
for (const [id, chord] of $overlay.chords) {
|
for (const [id, chord] of $overlay.chords) {
|
||||||
if (!chord.deleted) {
|
if (!chord.deleted) {
|
||||||
if (id !== JSON.stringify(chord.actions)) {
|
if (id !== JSON.stringify(chord.actions)) {
|
||||||
@@ -83,16 +99,28 @@
|
|||||||
} else {
|
} else {
|
||||||
await port.deleteChord({ actions: chord.actions });
|
await port.deleteChord({ actions: chord.actions });
|
||||||
}
|
}
|
||||||
|
syncProgress.set({
|
||||||
|
max: progressMax,
|
||||||
|
current: progressCurrent++,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [layer, actions] of $overlay.layout.entries()) {
|
for (const [layer, actions] of $overlay.layout.entries()) {
|
||||||
for (const [id, action] of actions) {
|
for (const [id, action] of actions) {
|
||||||
await port.setLayoutKey(layer + 1, id, action);
|
await port.setLayoutKey(layer + 1, id, action);
|
||||||
|
syncProgress.set({
|
||||||
|
max: progressMax,
|
||||||
|
current: progressCurrent++,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, setting] of $overlay.settings) {
|
for (const [id, setting] of $overlay.settings) {
|
||||||
await port.setSetting(id, setting);
|
await port.setSetting(id, setting);
|
||||||
|
syncProgress.set({
|
||||||
|
max: progressMax,
|
||||||
|
current: progressCurrent++,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yes, this is a completely arbitrary and unnecessary delay.
|
// Yes, this is a completely arbitrary and unnecessary delay.
|
||||||
@@ -102,24 +130,9 @@
|
|||||||
// would be if they click it every time they change a setting.
|
// would be if they click it every time they change a setting.
|
||||||
// Because of that, we don't need to show a fearmongering message such as
|
// Because of that, we don't need to show a fearmongering message such as
|
||||||
// "Your device will break after you click this 10,000 times!"
|
// "Your device will break after you click this 10,000 times!"
|
||||||
const virtualWriteTime = 1000;
|
if (needsCommit) {
|
||||||
const startStamp = performance.now();
|
await port.commit();
|
||||||
await new Promise<void>((resolve) => {
|
}
|
||||||
function animate() {
|
|
||||||
const delta = performance.now() - startStamp;
|
|
||||||
syncProgress.set({
|
|
||||||
max: virtualWriteTime,
|
|
||||||
current: delta,
|
|
||||||
});
|
|
||||||
if (delta >= virtualWriteTime) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
});
|
|
||||||
await port.commit();
|
|
||||||
|
|
||||||
$deviceLayout = $layout.map((layer) =>
|
$deviceLayout = $layout.map((layer) =>
|
||||||
layer.map<number>(({ action }) => action),
|
layer.map<number>(({ action }) => action),
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
codes: Map<number, KeyInfo>,
|
codes: Map<number, KeyInfo>,
|
||||||
): Promise<FlexSearch.Index> {
|
): Promise<FlexSearch.Index> {
|
||||||
if (chords.length === 0 || !browser) return index;
|
if (chords.length === 0 || !browser) return index;
|
||||||
|
|
||||||
index = new FlexSearch.Index({
|
index = new FlexSearch.Index({
|
||||||
tokenize: "full",
|
tokenize: "full",
|
||||||
encode(phrase: string) {
|
encode(phrase: string) {
|
||||||
@@ -149,36 +149,36 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let abort = false;
|
let abort = false;
|
||||||
abortIndexing = () => {
|
abortIndexing = () => {
|
||||||
abort = true;
|
abort = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const batchSize = 200;
|
const batchSize = 200;
|
||||||
const batches = Math.ceil(chords.length / batchSize);
|
const batches = Math.ceil(chords.length / batchSize);
|
||||||
|
|
||||||
for (let b = 0; b < batches; b++) {
|
for (let b = 0; b < batches; b++) {
|
||||||
if (abort) return index;
|
if (abort) return index;
|
||||||
|
|
||||||
const start = b * batchSize;
|
const start = b * batchSize;
|
||||||
const end = Math.min((b + 1) * batchSize, chords.length);
|
const end = Math.min((b + 1) * batchSize, chords.length);
|
||||||
const batch = chords.slice(start, end);
|
const batch = chords.slice(start, end);
|
||||||
|
|
||||||
const promises = batch.map((chord, i) => {
|
const promises = batch.map((chord, i) => {
|
||||||
const chordIndex = start + i;
|
const chordIndex = start + i;
|
||||||
progress = chordIndex + 1;
|
progress = chordIndex + 1;
|
||||||
|
|
||||||
if ("phrase" in chord) {
|
if ("phrase" in chord) {
|
||||||
const encodedChord = encodeChord(chord, osLayout, codes);
|
const encodedChord = encodeChord(chord, osLayout, codes);
|
||||||
return index.addAsync(chordIndex, encodedChord);
|
return index.addAsync(chordIndex, encodedChord);
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,159 +40,155 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<fieldset>
|
<nav>
|
||||||
<legend>{$LL.backup.TITLE()}</legend>
|
{#if $deviceMeta}
|
||||||
<label
|
{#each $deviceMeta?.settings as category}
|
||||||
><input
|
<a href={`#${category.name}`}>{titlecase(category.name)}</a>
|
||||||
type="checkbox"
|
{/each}
|
||||||
use:preference={"backup"}
|
{/if}
|
||||||
/>{$LL.backup.AUTO_BACKUP()}</label
|
<a href="#backup">Backup</a>
|
||||||
>
|
</nav>
|
||||||
<p class="disclaimer">
|
<div class="content">
|
||||||
{$LL.backup.DISCLAIMER()}
|
|
||||||
</p>
|
|
||||||
<div class="row" style="margin-top: auto">
|
|
||||||
<button onclick={() => downloadFile(createChordBackup())}>
|
|
||||||
<span class="icon">piano</span>
|
|
||||||
{$LL.configure.chords.TITLE()}
|
|
||||||
</button>
|
|
||||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
|
||||||
<span class="icon">keyboard</span>
|
|
||||||
{$LL.configure.layout.TITLE()}
|
|
||||||
</button>
|
|
||||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
|
||||||
<span class="icon">settings</span>
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="primary" onclick={downloadBackup}
|
|
||||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
|
||||||
>
|
|
||||||
<label class="button"
|
|
||||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
|
||||||
>settings_backup_restore</span
|
|
||||||
>{$LL.backup.RESTORE()}</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Device</legend>
|
|
||||||
<label
|
<label
|
||||||
>{$LL.deviceManager.AUTO_CONNECT()}<input
|
>{$LL.deviceManager.AUTO_CONNECT()}<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
use:preference={"autoConnect"}
|
use:preference={"autoConnect"}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
{#if $serialPort}
|
{#if $deviceMeta}
|
||||||
{#if $deviceMeta?.factoryDefaults?.settings}
|
{#each $deviceMeta.settings as category}
|
||||||
<button
|
<fieldset id={category.name}>
|
||||||
use:action={{ title: "Reset Settings" }}
|
<legend>
|
||||||
transition:fly={{ x: -8 }}
|
|
||||||
onclick={() => restoreFromFile($deviceMeta.factoryDefaults!.settings)}
|
|
||||||
><span class="icon">reset_settings</span>Reset Settings</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<button class="outline" use:popup={ResetPopup}>Recovery...</button>
|
|
||||||
{/if}
|
|
||||||
</fieldset>
|
|
||||||
{#if $deviceMeta}
|
|
||||||
{#each $deviceMeta.settings as category}
|
|
||||||
<fieldset>
|
|
||||||
<legend>
|
|
||||||
{#if category.items[0]?.name === "enable"}
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
use:setting={{ id: category.items[0].id }}
|
|
||||||
/>{titlecase(category.name)}</label
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{titlecase(category.name)}
|
{titlecase(category.name)}
|
||||||
|
</legend>
|
||||||
|
{#if category.description}
|
||||||
|
<p>{category.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</legend>
|
{#each category.items as item}
|
||||||
{#if category.description}
|
{#if item.unit === "H"}
|
||||||
<p>{category.description}</p>
|
<label
|
||||||
|
><input type="color" use:setting={{ id: item.id }} /> Color</label
|
||||||
|
>
|
||||||
|
{:else if item.unit !== "S" && item.unit !== "B"}
|
||||||
|
<label class:enable-item={item.name === "enable"}
|
||||||
|
>{#if item.enum}
|
||||||
|
<select class="value" use:setting={{ id: item.id }}>
|
||||||
|
{#each item.enum as name, value}
|
||||||
|
<option {value}>{titlecase(name)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if item.range[0] === 0 && item.range[1] === 1}
|
||||||
|
<input
|
||||||
|
class="value"
|
||||||
|
type="checkbox"
|
||||||
|
use:setting={{ id: item.id }}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="value unit">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={settingValue(item.range[0], item)}
|
||||||
|
max={settingValue(item.range[1], item)}
|
||||||
|
step={item.inverse !== undefined ||
|
||||||
|
item.scale !== undefined ||
|
||||||
|
item.step === undefined
|
||||||
|
? undefined
|
||||||
|
: settingValue(item.step, item)}
|
||||||
|
use:setting={{
|
||||||
|
id: item.id,
|
||||||
|
inverse: item.inverse,
|
||||||
|
scale: item.scale,
|
||||||
|
}}
|
||||||
|
/>{item.unit}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="title">{titlecase(item.name)}</div>
|
||||||
|
{#if item.description}
|
||||||
|
<div class="description">{item.description}</div>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<fieldset id="backup">
|
||||||
|
<legend>{$LL.backup.TITLE()}</legend>
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
use:preference={"backup"}
|
||||||
|
/>{$LL.backup.AUTO_BACKUP()}</label
|
||||||
|
>
|
||||||
|
<p class="disclaimer">
|
||||||
|
{$LL.backup.DISCLAIMER()}
|
||||||
|
</p>
|
||||||
|
<div class="row" style="margin-top: auto">
|
||||||
|
<button onclick={() => downloadFile(createChordBackup())}>
|
||||||
|
<span class="icon">piano</span>
|
||||||
|
{$LL.configure.chords.TITLE()}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||||
|
<span class="icon">keyboard</span>
|
||||||
|
{$LL.configure.layout.TITLE()}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||||
|
<span class="icon">settings</span>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" onclick={downloadBackup}
|
||||||
|
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||||
|
>
|
||||||
|
<label class="button"
|
||||||
|
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||||
|
>settings_backup_restore</span
|
||||||
|
>{$LL.backup.RESTORE()}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{#if $serialPort}
|
||||||
|
{#if $deviceMeta?.factoryDefaults?.settings}
|
||||||
|
<button
|
||||||
|
use:action={{ title: "Reset Settings" }}
|
||||||
|
transition:fly={{ x: -8 }}
|
||||||
|
onclick={() =>
|
||||||
|
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
|
||||||
|
><span class="icon">reset_settings</span>Reset Settings</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#each category.items as item}
|
<button use:popup={ResetPopup}>Recovery...</button>
|
||||||
{#if item.name !== "enable"}
|
{/if}
|
||||||
<label
|
</div>
|
||||||
>{#if item.enum}
|
</div>
|
||||||
<select use:setting={{ id: item.id }}>
|
|
||||||
{#each item.enum as name, value}
|
|
||||||
<option {value}>{titlecase(name)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{:else if item.range[0] === 0 && item.range[1] === 1}
|
|
||||||
<input type="checkbox" use:setting={{ id: item.id }} />
|
|
||||||
{:else}
|
|
||||||
<span class="unit"
|
|
||||||
><input
|
|
||||||
type="number"
|
|
||||||
min={settingValue(item.range[0], item)}
|
|
||||||
max={settingValue(item.range[1], item)}
|
|
||||||
step={settingValue(item.step, item)}
|
|
||||||
use:setting={{
|
|
||||||
id: item.id,
|
|
||||||
inverse: item.inverse,
|
|
||||||
scale: item.scale,
|
|
||||||
}}
|
|
||||||
/>{item.unit}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if item.description}
|
|
||||||
<span
|
|
||||||
>{titlecase(item.name)}
|
|
||||||
<p>{item.description}</p></span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{titlecase(item.name)}
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
section {
|
section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
scroll-behavior: smooth;
|
||||||
flex-flow: row wrap;
|
max-width: 20cm;
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
margin-block: auto;
|
|
||||||
padding-block-end: 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.outline {
|
legend {
|
||||||
border: 1px solid currentcolor;
|
color: var(--md-sys-color-primary);
|
||||||
border-radius: 8px;
|
font-size: 32px;
|
||||||
height: 2em;
|
|
||||||
margin-block: 2em;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
legend,
|
|
||||||
legend > label {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
legend:has(label) {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
legend:not(:has(label)) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
@@ -200,22 +196,28 @@
|
|||||||
fieldset {
|
fieldset {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
max-width: 400px;
|
width: 100%;
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
margin-inline: 0;
|
||||||
border-radius: 24px;
|
border: none;
|
||||||
|
margin-block-end: 32px;
|
||||||
|
|
||||||
/*&:has(> legend input:not(:checked)) > :not(legend) {
|
> p {
|
||||||
pointer-events: none;
|
padding-inline-start: 16px;
|
||||||
opacity: 0.7;
|
}
|
||||||
}*/
|
|
||||||
|
|
||||||
> label {
|
> label {
|
||||||
|
appearance: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
|
height: auto;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
margin-block: 4px;
|
margin-block: 4px;
|
||||||
|
|
||||||
@@ -228,6 +230,26 @@
|
|||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.enable-item {
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
margin-inline-start: 8px;
|
||||||
|
padding-inline-end: 16px;
|
||||||
|
padding-inline-start: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-inline-start: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
text-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
@@ -275,6 +297,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--md-sys-color-secondary);
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
// stylelint-disable-next-line
|
// stylelint-disable-next-line
|
||||||
label:global(:has(.pending-changes)) {
|
label:global(:has(.pending-changes)) {
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
@@ -292,9 +324,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
margin-block: 8px;
|
margin-block: 8px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -114,7 +114,6 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
section {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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