60 Commits

Author SHA1 Message Date
John de St Germain
29756834f8 Fix misspelling 2024-05-13 09:20:46 -05:00
3dd91a1cea 1.5.1 2024-04-29 11:19:37 +02:00
cbcf705f71 feat: massively improved chord search
fixes #119
2024-04-29 11:18:23 +02:00
4007810c7b fix: can't edit blank actions
fixes #110
2024-04-29 09:35:22 +02:00
f322435c41 1.5.0 2024-04-26 17:13:55 +02:00
587375e654 fix: chord conflict shows "undefined" 2024-04-26 17:12:59 +02:00
0500a723de fix: remove tab hotkeys 2024-04-26 17:04:39 +02:00
26dcc56aca feat: and the ability to duplicate chords
resolves #100
2024-04-23 18:21:04 +02:00
20b65813bf fix: chord action change indicator has weird placement 2024-04-23 18:06:04 +02:00
87b23c04b1 fix: strikethrough misaligned 2024-04-23 17:56:53 +02:00
8b2bc6d109 fix: chord page auto-focuses input on first load
fixes #111
2024-04-23 17:46:05 +02:00
19cf0b26b3 feat: add vocabulary export 2024-04-23 17:38:33 +02:00
3e72dd3cb8 fix: new actions show as compound chords
fixes #107
feat: add ability to edit compound chords
2024-04-23 17:16:21 +02:00
a40daefbad fix: action selector auto-focus
fixes #108
2024-04-23 16:57:53 +02:00
77d4a90519 fix: can't cancel the chord input recording
fixes #109
2024-04-23 16:52:36 +02:00
c9a031a1fd fix: hitting the enter key when focusing elements opens the reset menu
fixes #114
2024-04-23 16:48:18 +02:00
254a0c1aec fix: chording press/release has wrong max values
fixes #113
2024-04-23 16:33:58 +02:00
bd75012cf1 fix: svelte-check issues 2024-04-06 19:25:03 +02:00
4b738bb340 fix: hotkeys
fixes #20
2024-04-06 19:05:42 +02:00
3af65106bf feat: auto-focus new chords 2024-04-06 18:08:16 +02:00
8087d10d5a fix: auto focus reset challenge input 2024-04-06 18:06:10 +02:00
2782966505 fix: action search
fix: can't browse actions after searching and clearing
fix: can't use esc to exit action search
fix: improve action search performance
2024-04-06 18:04:13 +02:00
5b6d369101 feat: add pre-ccos hint when connection errors
resolves #99
2024-04-06 17:43:43 +02:00
b423d1c661 feat: add store link
feat: rebalance footer
resolves #88
2024-04-06 17:38:27 +02:00
92a3c6012f feat: add random tips to the chords page
resolves #81
2024-04-06 17:33:46 +02:00
8ec11c7ec9 fix: reset options challenge box not filling the dialog
fixes #87
2024-04-06 16:54:56 +02:00
5c8eb1d19f feat: allow creation of single letter chords
resolves #84
2024-04-06 16:52:18 +02:00
91a044bbba fix: some ccx stuff 2024-04-06 16:49:30 +02:00
1a6c85a361 fix: can't search ccx chords
fixes #98

feat: improve search page responsiveness
2024-04-06 16:42:10 +02:00
ecef11ac2d fix: settings page header change indicator 2024-04-06 16:12:20 +02:00
a23af9ba9d fix: lite rgb 2024-04-06 15:56:02 +02:00
93849f250f feat: fully expand linux permission guide
fixes #103
2024-04-06 15:46:31 +02:00
33890b0aa8 feat: improve responsiveness 2024-04-06 15:37:13 +02:00
6f925de1af feat: charachorder lite brightness & color settings 2024-04-06 14:41:26 +02:00
d45fe43f17 feat: and warning about flatpak and snaps
resolves #104
2024-04-06 14:34:20 +02:00
59788f059d fix: add ascii plus
fixes #105
2024-04-06 14:30:24 +02:00
2808973ad0 feat: enable stricter type checking options
feat: make the app more fault tolerant
2024-04-06 14:28:23 +02:00
bef51d2a7d refactor: update dependencies 2024-04-06 13:32:53 +02:00
854ab6d3be refactor: use standard prettier formatting 2024-04-06 13:15:35 +02:00
86ec8651b6 feat: some forced color adjustments 2024-03-16 14:41:39 +01:00
4e4bff02a0 feat: react to user contrast preferences 2024-03-16 13:09:21 +01:00
5d4dbc7e2a feat: improve legebility for inactive layout layers 2024-03-15 23:46:37 +01:00
dfd1c0bcbd feat: add suspense logs in serial console 2024-03-05 18:14:49 +01:00
6ac2cd1993 fix: add timeout for device responses 2024-03-05 18:12:56 +01:00
7256dc50d4 feat: new action codes 2024-03-04 21:07:45 +01:00
f0ad19e6c2 1.4.0 2024-02-14 00:55:10 +01:00
9022a09b4c fix: allow 0-return on chord deletion 2024-02-07 00:11:10 +01:00
7e3e61afd7 feat: force backup when putting the device into bootloader mode 2024-02-05 21:03:36 +01:00
08f594d164 feat: read full chord actions every time
feat: add special view for compound chords
fix: make it possible to delete compound chords
fixes #94
2024-02-05 20:39:05 +01:00
046595b51f feat: add device firmware update instructions
resolves #89
2024-02-05 20:08:50 +01:00
fbc5303690 fix: backup title is confusing
fixes #83
2024-02-05 19:55:26 +01:00
ad41d39bfb fix: remove logging statement
fixes #80
2024-02-05 19:50:37 +01:00
6faaa18b3b 1.3.2 2024-01-30 19:49:52 +01:00
6ab6959129 fix: disallow null inputs when editing
feat: allow special inputs while creating a chord input
fixes #93
2024-01-30 19:49:10 +01:00
44d89d3f35 1.3.1 2024-01-24 18:55:46 +01:00
eaf0adaf01 fix: sort legacy chord inputs 2024-01-24 18:55:31 +01:00
5b6a5ea36d 1.3.0 2024-01-20 22:24:39 +01:00
14cbb5553b feat: add auto-space info 2024-01-20 22:24:00 +01:00
duianto
8ed72fe958 fix: typo 2024-01-11 09:36:33 +01:00
06b83f79ef feat: add refresh button
resolves #82
2024-01-05 00:12:42 +01:00
120 changed files with 6290 additions and 5262 deletions

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -1,6 +0,0 @@
module.exports = {
...require("@theaninova/prettier-config"),
plugins: ["prettier-plugin-svelte"],
pluginSearchDirs: ["."],
overrides: [{files: "*.svelte", options: {parser: "svelte"}}],
}

View File

@@ -41,6 +41,7 @@ const config = {
"save",
"settings_backup_restore",
"sort",
"shopping_bag",
"filter_list",
"settings_power",
"link",
@@ -92,6 +93,7 @@ const config = {
"stat_2",
"description",
"add_circle",
"refresh",
],
codePoints: {
speed: "e9e4",
@@ -111,6 +113,6 @@ const config = {
stat_minus_2: "e69c",
stat_2: "e699",
},
}
};
export default config
export default config;

5551
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "charachorder-device-manager",
"version": "1.2.0",
"version": "1.5.1",
"license": "AGPL-3.0-or-later",
"private": true,
"repository": {
@@ -13,72 +13,69 @@
},
"scripts": {
"dev": "npm-run-all --parallel vite typesafe-i18n",
"dev:external": "npm-run-all --parallel vite:external typesafe-i18n",
"dev:tauri": "tauri dev",
"vite": "vite dev",
"vite:external": "vite --host",
"build": "typesafe-i18n --no-watch && vite build",
"build:tauri": "tauri build",
"tauri": "tauri",
"test": "vitest run --coverage",
"preview": "vite preview",
"postinstall": "patch-package",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"minify-icons": "node src/tools/minify-icon-font.js",
"version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write .",
"lint": "prettier --check .",
"format": "prettier --write .",
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.2.5",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.9.0",
"@codemirror/state": "^6.2.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.16",
"@fontsource-variable/noto-sans-mono": "^5.0.17",
"@codemirror/autocomplete": "^6.15.0",
"@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.1",
"@codemirror/state": "^6.4.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.27",
"@fontsource-variable/noto-sans-mono": "^5.0.19",
"@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.24.1",
"@sveltejs/vite-plugin-svelte": "^2.4.5",
"@tauri-apps/api": "^1.4.0",
"@tauri-apps/cli": "^1.4.0",
"@theaninova/prettier-config": "^1.0.0",
"@types/dom-view-transitions": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/w3c-web-serial": "^1.0.3",
"@sveltejs/kit": "^1.30.4",
"@sveltejs/vite-plugin-svelte": "^2.5.3",
"@tauri-apps/api": "^1.5.3",
"@tauri-apps/cli": "^1.5.11",
"@types/dom-view-transitions": "^1.0.4",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.2.7",
"autoprefixer": "^10.4.15",
"@vite-pwa/sveltekit": "^0.2.10",
"autoprefixer": "^10.4.19",
"codemirror": "^6.0.1",
"cypress": "^13.1.0",
"flexsearch": "^0.7.31",
"cypress": "^13.7.2",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.2",
"glob": "^10.3.4",
"hotkeys-js": "^3.12.0",
"glob": "^10.3.12",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"prettier": "^3.0.3",
"prettier-plugin-svelte": "^3.0.3",
"sass": "^1.66.1",
"stylelint": "^15.10.3",
"stylelint-config-clean-order": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"sass": "^1.74.1",
"stylelint": "^15.11.0",
"stylelint-config-clean-order": "^5.4.2",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^13.0.0",
"stylelint-config-standard-scss": "^11.0.0",
"svelte": "^4.2.0",
"svelte-check": "^3.5.1",
"svelte-preprocess": "^5.0.4",
"stylelint-config-recommended-scss": "^13.1.0",
"stylelint-config-standard-scss": "^11.1.0",
"svelte": "^4.2.12",
"svelte-check": "^3.6.9",
"svelte-preprocess": "^5.1.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-plugin-mkcert": "^1.16.0",
"vite-plugin-pwa": "^0.17.4",
"vitest": "^0.34.4"
"typescript": "^5.4.4",
"vite": "^4.5.3",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.17.5",
"vitest": "^0.34.6"
},
"type": "module"
}

View File

@@ -1,21 +0,0 @@
diff --git a/node_modules/@types/flexsearch/index.d.ts b/node_modules/@types/flexsearch/index.d.ts
index ecde8e7..64a5f1e 100755
--- a/node_modules/@types/flexsearch/index.d.ts
+++ b/node_modules/@types/flexsearch/index.d.ts
@@ -6,7 +6,6 @@
/************************************/
/* Utils */
/************************************/
-export type Id = number | string;
export type Limit = number;
export type ExportHandler<T> = (id: string | number, value: T) => void;
export type AsyncCallback<T = undefined> = T extends undefined ? () => void : (result: T) => void;
@@ -165,7 +164,7 @@ export type IndexSearchResult = Id[];
* * Usage: https://github.com/nextapps-de/flexsearch#usage
*/
-export class Index {
+export default class Index<ID extends number | string = number> {
constructor(x?: Preset | IndexOptions<string>);
add(id: Id, item: string): this;
append(id: Id, item: string): this;

View File

@@ -1,153 +0,0 @@
diff --git a/node_modules/flexsearch/index.d.ts b/node_modules/flexsearch/index.d.ts
deleted file mode 100644
index 9f39f41..0000000
--- a/node_modules/flexsearch/index.d.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-declare module "flexsearch" {
- export interface Index<T> {
- readonly id: string;
- readonly index: string;
- readonly length: number;
-
- init(options?: CreateOptions): this;
- info(): {
- id: any;
- items: any;
- cache: any;
- matcher: number;
- worker: any;
- threshold: any;
- depth: any;
- resolution: any;
- contextual: boolean;
- };
- add(o: T): this;
- add(id: number, o: string): this;
-
- // Result without pagination -> T[]
- search(
- query: string,
- options: number | SearchOptions,
- callback: (results: T[]) => void
- ): void;
- search(query: string, options?: number | SearchOptions): Promise<T[]>;
- search(
- options: SearchOptions & { query: string },
- callback: (results: T[]) => void
- ): void;
- search(options: SearchOptions & { query: string }): Promise<T[]>;
-
- // Result with pagination -> SearchResults<T>
- search(
- query: string,
- options: number | (SearchOptions & { page?: boolean | Cursor }),
- callback: (results: SearchResults<T>) => void
- ): void;
- search(
- query: string,
- options?: number | (SearchOptions & { page?: boolean | Cursor })
- ): Promise<SearchResults<T>>;
- search(
- options: SearchOptions & { query: string; page?: boolean | Cursor },
- callback: (results: SearchResults<T>) => void
- ): void;
- search(
- options: SearchOptions & { query: string; page?: boolean | Cursor }
- ): Promise<SearchResults<T>>;
-
- update(id: number, o: T): this;
- remove(id: number): this;
- clear(): this;
- destroy(): this;
- addMatcher(matcher: Matcher): this;
-
- where(whereObj: { [key: string]: string } | ((o: T) => boolean)): T[];
- encode(str: string): string;
- export(
- callback: (key: string, data: any) => any,
- self?: this,
- field?: string,
- index_doc?: Number,
- index?: Number
- ): Promise<boolean>;
- import(exported: string): this;
- }
-
- interface SearchOptions {
- limit?: number;
- suggest?: boolean;
- where?: { [key: string]: string };
- field?: string | string[];
- bool?: "and" | "or" | "not";
- //TODO: Sorting
- }
-
- interface SearchResults<T> {
- page?: Cursor;
- next?: Cursor;
- result: T[];
- }
-
- interface Document {
- id: string;
- field: any;
- }
-
- export type CreateOptions = {
- profile?: IndexProfile;
- tokenize?: DefaultTokenizer | TokenizerFn;
- split?: RegExp;
- encode?: DefaultEncoder | EncoderFn | false;
- cache?: boolean | number;
- async?: boolean;
- worker?: false | number;
- depth?: false | number;
- threshold?: false | number;
- resolution?: number;
- stemmer?: Stemmer | string | false;
- filter?: FilterFn | string | false;
- rtl?: boolean;
- doc?: Document;
- };
-
- // limit number Sets the limit of results.
- // suggest true, false Enables suggestions in results.
- // where object Use a where-clause for non-indexed fields.
- // field string, Array<string> Sets the document fields which should be searched. When no field is set, all fields will be searched. Custom options per field are also supported.
- // bool "and", "or" Sets the used logical operator when searching through multiple fields.
- // page true, false, cursor Enables paginated results.
-
- type IndexProfile =
- | "memory"
- | "speed"
- | "match"
- | "score"
- | "balance"
- | "fast";
- type DefaultTokenizer = "strict" | "forward" | "reverse" | "full";
- type TokenizerFn = (str: string) => string[];
- type DefaultEncoder = "icase" | "simple" | "advanced" | "extra" | "balance";
- type EncoderFn = (str: string) => string;
- type Stemmer = { [key: string]: string };
- type Matcher = { [key: string]: string };
- type FilterFn = (str: string) => boolean;
- type Cursor = string;
-
- export default class FlexSearch {
- static create<T>(options?: CreateOptions): Index<T>;
- static registerMatcher(matcher: Matcher): typeof FlexSearch;
- static registerEncoder(name: string, encoder: EncoderFn): typeof FlexSearch;
- static registerLanguage(
- lang: string,
- options: { stemmer?: Stemmer; filter?: string[] }
- ): typeof FlexSearch;
- static encode(name: string, str: string): string;
- }
-}
-
-// FlexSearch.create(<options>)
-// FlexSearch.registerMatcher({KEY: VALUE})
-// FlexSearch.registerEncoder(name, encoder)
-// FlexSearch.registerLanguage(lang, {stemmer:{}, filter:[]})
-// FlexSearch.encode(name, string)

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.2.0"
version = "1.5.1"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "1.2.0" },
"package": { "productName": "amacc1ng", "version": "1.5.1" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

2
src/app.d.ts vendored
View File

@@ -11,4 +11,4 @@ declare global {
}
}
export {}
export {};

24
src/env.d.ts vendored
View File

@@ -1,19 +1,21 @@
/// <references types="vite/client" />
interface ImportMetaEnv {
readonly TAURI_FAMILY?: string
readonly TAURI_PLATFORM_VERSION?: string
readonly TAURI_TARGET_TRIPLE?: string
readonly TAURI_ARCH?: string
readonly TAURI_DEBUG?: boolean
readonly TAURI_PLATFORM_TYPE?: string
readonly TAURI_FAMILY?: string;
readonly TAURI_PLATFORM_VERSION?: string;
readonly TAURI_TARGET_TRIPLE?: string;
readonly TAURI_ARCH?: string;
readonly TAURI_DEBUG?: boolean;
readonly TAURI_PLATFORM_TYPE?: string;
readonly VITE_HOMEPAGE_URL: string
readonly VITE_BUGS_URL: string
readonly VITE_DOCS_URL: string
readonly VIET_LEARN_URL: string
readonly VITE_HOMEPAGE_URL: string;
readonly VITE_BUGS_URL: string;
readonly VITE_DOCS_URL: string;
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv;
}

View File

@@ -1,4 +1,4 @@
import type {Translation} from "../i18n-types"
import type { Translation } from "../i18n-types";
const de = {
TITLE: "CharaChorder Gerätemanager",
@@ -14,12 +14,13 @@ const de = {
sync: {
TITLE_READ: "Neueste Änderungen werden abgerufen",
TITLE_WRITE: "Änderungen werden gespeichert",
RELOAD: "Neu laden",
},
backup: {
TITLE: "Verlauf speichern",
TITLE: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER:
"Der Verlauf wird als Backup in diesem Browser gespeichert. Der Verlauf bleibt auf diesem Computer.",
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen",
},
@@ -34,7 +35,8 @@ const de = {
filter: {
ALL: "Alle",
},
LIVE_LAYOUT_INFO: "Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
LIVE_LAYOUT_INFO:
"Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
},
@@ -65,12 +67,13 @@ const de = {
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
NO_DEVICE: "Kein Gerät verbunden",
LINUX_PERMISSIONS:
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden.",
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden. Flatpak und Snap Anwendungen benötigen zusätzliche Berechtigungen oder funktionieren möglicherweise gar nicht.",
bootMenu: {
TITLE: "Bootmenü",
REBOOT: "Neustarten",
BOOTLOADER: "Bootloader",
POWER_WARNING: "Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
POWER_WARNING:
"Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
},
},
browserWarning: {
@@ -82,7 +85,8 @@ const de = {
INFO_BROWSER_PREFIX:
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
INFO_BROWSER_SUFFIX:
" sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP:
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
},
@@ -116,10 +120,11 @@ const de = {
conflict: {
TITLE: "Akkordkonflikt",
DESCRIPTION:
"Der Akkord {0} würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
"Der Akkord würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
CONFIRM: "Überschreiben",
ABORT: "Überspringen",
},
VOCABULARY: "Vokabelliste",
TRY_TYPING: "Versuche hier zu tippen",
},
layout: {
@@ -134,6 +139,6 @@ const de = {
RUN: "Ausführen",
},
},
} satisfies Translation
} satisfies Translation;
export default de
export default de;

View File

@@ -1,8 +1,9 @@
import type {BaseTranslation} from "../i18n-types"
import type { BaseTranslation } from "../i18n-types";
const en = {
TITLE: "CharaChorder Device Manager",
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.",
DESCRIPTION:
"The device manager and configuration tool for CharaChorder devices.",
saveActions: {
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo",
@@ -12,15 +13,17 @@ const en = {
TITLE: "Update your device",
},
backup: {
TITLE: "Store History",
TITLE: "Local backup",
INDIVIDUAL: "Individual backups",
DISCLAIMER: "Your history is stored as a backup in this browser. The history remains on your computer.",
DISCLAIMER:
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore",
},
sync: {
TITLE_READ: "Reading latest changes",
TITLE_WRITE: "Saving changes to device",
RELOAD: "Reload",
},
modal: {
CLOSE: "Close",
@@ -63,24 +66,28 @@ const en = {
TERMINAL: "Terminal",
APPLY_SETTINGS: "Flash changes to device",
NO_DEVICE: "No device connected",
LINUX_PERMISSIONS: "Most Linux based systems need adjusted permissions in order to connect your device.",
LINUX_PERMISSIONS:
"Most Linux based systems need adjusted permissions in order to connect your device. Flatpak or Snap versions in particular might need additional permissions or may not work at all.",
bootMenu: {
TITLE: "Boot Menu",
REBOOT: "Reboot",
BOOTLOADER: "Bootloader",
POWER_WARNING: "To reboot from bootloader you need to physically reconnect your device.",
POWER_WARNING:
"To reboot from bootloader you need to physically reconnect your device.",
},
},
browserWarning: {
TITLE: "Warning",
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_PREFIX:
"Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_INFIX: "serial connections",
INFO_SERIAL_SUFFIX: ".",
INFO_BROWSER_PREFIX:
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
INFO_BROWSER_SUFFIX: ".",
DOWNLOAD_APP: "Chrome or Edge are officially supported, but other browsers might work as well.",
DOWNLOAD_APP:
"Chrome or Edge are officially supported, but other browsers might work as well.",
},
changes: {
TITLE: "Import changes",
@@ -112,10 +119,11 @@ const en = {
conflict: {
TITLE: "Chord conflict",
DESCRIPTION:
"Your chord {0} conflicts with an existing chord. Are you sure you want to overwrite this chord?",
"Your chord conflicts with an existing chord. Are you sure you want to overwrite this chord?",
CONFIRM: "Overwrite",
ABORT: "Skip",
},
VOCABULARY: "Vocabulary",
TRY_TYPING: "Try typing here",
},
layout: {
@@ -130,6 +138,6 @@ const en = {
RUN: "Run",
},
},
} satisfies BaseTranslation
} satisfies BaseTranslation;
export default en
export default en;

View File

@@ -1,10 +1,12 @@
import type {FormattersInitializer} from "typesafe-i18n"
import type {Locales, Formatters} from "./i18n-types"
import type { FormattersInitializer } from "typesafe-i18n";
import type { Locales, Formatters } from "./i18n-types";
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
export const initFormatters: FormattersInitializer<Locales, Formatters> = (
_locale: Locales,
) => {
const formatters: Formatters = {
// add your formatter functions here
}
};
return formatters
}
return formatters;
};

View File

@@ -28,6 +28,9 @@ actions:
42:
id: "*"
title: Asterisk
43:
id: "+"
title: Plus
58:
id: ":"
title: Colon

View File

@@ -1,6 +1,9 @@
name: CharaChorder
description: CharaChorder specific actions
actions:
0:
id: "NO_ACTION"
display: "No Action"
528:
id: "RESTART"
title: Restart Device
@@ -58,6 +61,7 @@ actions:
544:
variantOf: 36
id: "SPACERIGHT"
display: " "
title: Right Spacebar (eg CC Lite)
icon: space_bar
variant: right
@@ -91,3 +95,19 @@ actions:
<<: *tertiary_keymap
id: "KM_3_R"
variant: right
576:
id: ACTION_DELAY_1000
icon: clock_loader_90
description: Wait for one second
577:
id: ACTION_DELAY_100
icon: clock_loader_60
description: Wait for 100 milliseconds
578:
id: ACTION_DELAY_10
icon: clock_loader_40
description: Wait for 10 milliseconds
579:
id: ACTION_DELAY_1
icon: clock_loader_10
description: Wait for one millisecond

View File

@@ -6,41 +6,49 @@ actions:
id: "LEFT_CTRL"
display: CTRL
title: Control Keyboard Modifier
keyCode: ControlLeft
variant: left
513: &left_shift
id: "LEFT_SHIFT"
title: Shift Keyboard Modifier
keyCode: ShiftLeft
variant: left
icon: shift
514: &left_alt
id: "LEFT_ALT"
display: ALT
title: Alt Keyboard Modifier
keyCode: AltLeft
variant: left
515: &left_gui
id: "LEFT_GUI"
title: GUI Keyboard Modifier
keyCode: MetaLeft
icon: apps
variant: left
516:
variationOf: 512
<<: *left_ctrl
id: "RIGHT_CTRL"
keyCode: ControlRight
variant: right
517:
variationOf: 513
<<: *left_shift
id: "RIGHT_SHIFT"
keyCode: ShiftRight
variant: right
518:
variationOf: 514
<<: *left_alt
id: "RIGHT_ALT"
keyCode: AltRight
variant: right
519:
variationOf: 515
<<: *left_gui
id: "RIGHT_GUI"
keyCode: MetaRight
variant: right
520:
id: "RELEASE_MOD"

View File

@@ -1,19 +1,19 @@
export interface KeymapCategory {
name: string
description: string
icon?: string
display?: string
type?: "unassigned"
actions: Record<number, Partial<ActionInfo>>
name: string;
description: string;
icon?: string;
display?: string;
type?: "unassigned";
actions: Record<number, Partial<ActionInfo>>;
}
export interface ActionInfo {
id: string
title: string
icon: string
display: string
description: string
variant: "left" | "right"
variantOf: number
keyCode: string
id: string;
title: string;
icon: string;
display: string;
description: string;
variant: "left" | "right";
variantOf: number;
keyCode: string;
}

View File

@@ -3,7 +3,9 @@ description: OS-Layout sensitive keycodes
actions:
256:
id: "KSC_00"
icon: block
title: No Key Pressed
description: Also commonly used at the end of a chord to remove auto-spaces
257:
id: "KSC_01"
title: Keyboard Error Roll Over
@@ -918,35 +920,27 @@ actions:
description: Not required to be supported by any OS
480:
id: "KSC_E0"
keyCode: "ControlLeft"
title: Keyboard Left Control
481:
id: "KSC_E1"
keyCode: "ShiftLeft"
title: Keyboard Left Shift
482:
id: "KSC_E2"
keyCode: "AltLeft"
title: Keyboard Left Alt
483:
id: "KSC_E3"
keyCode: "MetaLeft"
title: Keyboard Left GUI
484:
id: "KSC_E4"
keyCode: "ControlRight"
title: Keyboard Right Control
485:
id: "KSC_E5"
keyCode: "ShiftRight"
title: Keyboard Right Shift
486:
id: "KSC_E6"
keyCode: "AltRight"
title: Keyboard Right Alt
487:
id: "KSC_E7"
keyCode: "MetaRight"
title: Keyboard Right GUI
488:
id: "KSC_E8"

View File

@@ -15,10 +15,10 @@ col:
- key: 64
- key: 65
- key: 66
size: [ 2, 1 ]
size: [2, 1]
- row:
- key: 39
size: [ 1.5, 1 ]
size: [1.5, 1]
- key: 40
- key: 41
- key: 42
@@ -32,10 +32,10 @@ col:
- key: 50
- key: 51
- key: 52
size: [ 1.5, 1 ]
size: [1.5, 1]
- row:
- key: 26
size: [ 1.75, 1 ]
size: [1.75, 1]
- key: 27
- key: 28
- key: 29
@@ -48,10 +48,10 @@ col:
- key: 36
- key: 37
- key: 38
size: [ 2.25, 1 ]
size: [2.25, 1]
- row:
- key: 12
size: [ 2, 1 ]
size: [2, 1]
- key: 13
- key: 14
- key: 15
@@ -68,20 +68,19 @@ col:
- row:
- key: 0
- key: 1
size: [ 1.25, 1 ]
size: [1.25, 1]
- key: 2
size: [ 1.25, 1 ]
size: [1.25, 1]
- key: 3
size: [ 2, 1 ]
size: [2, 1]
- key: 4
- key: 5
- key: 6
size: [ 2, 1 ]
size: [2, 1]
- key: 7
size: [ 1.25, 1 ]
size: [1.25, 1]
- key: 8
size: [ 1.25, 1 ]
size: [1.25, 1]
- key: 9
- key: 10
- key: 11

View File

@@ -0,0 +1,38 @@
[
"You can use DUP+i to create chords on the fly in any text box",
"This site is open source! Check out the full source code on GitHub in the bottom left",
"Two letter chords can be activated accidentally in chentry. Be cautious around them",
"More inputs in a chord increase the tolerance, making them easier to activate",
"The maximum number of outputs in a chord is 256",
"You can create backups of your device on the top right",
"For programming you should set your auto-delete timeout to about 200ms",
"Large parts of this site were written on a CC1",
"I use VIM btw...",
"I use NixOS btw...",
"You can hold shift on the undo button to undo all changes",
"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",
"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",
"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",
"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!",
"Spurring is a chording only mode which is more advanced, but can greatly improve typing speed when mastered",
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
"Don't be afraid to delete chords you keep getting wrong",
"Most people find it easier to start their chord library from scratch rather than learning someone else's",
"A common techinque to deal with conflicts is to add DUP or the same key mirrored on the other hand",
"A longer chord is not always more difficult",
"Riley Keen made headlines when his Monkeytype score of 500WPM using a CC1 got him banned off the site",
"A 3d press refers to pressing down into a 5-way switch",
"The serial communication protocol used by CCOS is documented on docs.charachorder.com",
"The 'CCOS is ready' message can be turned off in the settings",
"Most people using the CC1 don't change the a-z key layout, as further modification provides very little benefit",
"Using VIM on the default CC1 a-z layout is perfectly doable, it's just a matter of getting used to it",
"You can use Nexus to track words you might want to add to your chord library",
"The CC1 default layout was 80% science, 20% art",
"There is little to no reason to use hjkl in VIM on a CC1 since the arrows keys are so close already",
"The device manager automatically creates a backup for you when you reboot your device into the bootloader",
"You can use \"compound\", \"macro\", \"suffix\" and \"cursor warp\" in the chord search to find specific types of chords",
"You can search for chord inputs by using a leading \"+\", for example \"+a +DUP\" will show all chords with inputs that contain both a and DUP"
]

View File

@@ -4,33 +4,45 @@ import type {
CharaFile,
CharaLayoutFile,
CharaSettingsFile,
} from "$lib/share/chara-file.js"
import type {Change} from "$lib/undo-redo.js"
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js"
import {get} from "svelte/store"
import {serialPort} from "../serial/connection"
import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout"
import {isCsvChords, csvChordsToJson} from "./compat/legacy-chords"
} from "$lib/share/chara-file.js";
import type { Change } from "$lib/undo-redo.js";
import {
changes,
ChangeType,
chords,
layout,
settings,
} from "$lib/undo-redo.js";
import { get } from "svelte/store";
import { serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
export function downloadFile<T extends CharaFile<string>>(contents: T) {
const downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(contents)], {type: "application/json"}))
const element = document.createElement("a")
const downloadUrl = URL.createObjectURL(
new Blob([JSON.stringify(contents)], { type: "application/json" }),
);
const element = document.createElement("a");
element.setAttribute(
"download",
`${contents.type}-${get(serialPort)?.device}-${new Date().toISOString()}.json`,
)
element.href = downloadUrl
element.setAttribute("target", "_blank")
element.click()
URL.revokeObjectURL(downloadUrl)
`${contents.type}-${
get(serialPort)?.device
}-${new Date().toISOString()}.json`,
);
element.href = downloadUrl;
element.setAttribute("target", "_blank");
element.click();
URL.revokeObjectURL(downloadUrl);
}
export function downloadBackup() {
downloadFile<CharaBackupFile>({
charaVersion: 1,
type: "backup",
history: [[createChordBackup(), createLayoutBackup(), createSettingsBackup()]],
})
history: [
[createChordBackup(), createLayoutBackup(), createSettingsBackup()],
],
});
}
export function createLayoutBackup(): CharaLayoutFile {
@@ -38,127 +50,143 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1,
type: "layout",
device: get(serialPort)?.device,
layout: get(layout).map(it => it.map(it => it.action)) as [number[], number[], number[]],
}
layout: get(layout).map((it) => it.map((it) => it.action)) as [
number[],
number[],
number[],
],
};
}
export function createChordBackup(): CharaChordFile {
return {charaVersion: 1, type: "chords", chords: get(chords).map(it => [it.actions, it.phrase])}
return {
charaVersion: 1,
type: "chords",
chords: get(chords).map((it) => [it.actions, it.phrase]),
};
}
export function createSettingsBackup(): CharaSettingsFile {
return {charaVersion: 1, type: "settings", settings: get(settings).map(it => it.value)}
return {
charaVersion: 1,
type: "settings",
settings: get(settings).map((it) => it.value),
};
}
export async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const text = await input.text()
const input = (event.target as HTMLInputElement).files![0];
if (!input) return;
const text = await input.text();
if (input.name.endsWith(".json")) {
restoreFromFile(JSON.parse(text))
restoreFromFile(JSON.parse(text));
} else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text))
restoreFromFile(csvLayoutToJson(text));
} else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text))
restoreFromFile(csvChordsToJson(text));
} else {
alert("Unknown backup format")
}
}
export function restoreFromFile(
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
) {
if (file.charaVersion !== 1) throw new Error("Incompatible backup")
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
switch (file.type) {
case "backup": {
const recent = file.history[0]
const recent = file.history[0];
if (!recent) return;
if (recent[1].device !== get(serialPort)?.device) {
alert("Backup is incompatible with this device")
throw new Error("Backup is incompatible with this device")
alert("Backup is incompatible with this device");
throw new Error("Backup is incompatible with this device");
}
changes.update(changes => {
changes.update((changes) => {
changes.push(
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
)
return changes
})
break
);
return changes;
});
break;
}
case "chords": {
changes.update(changes => {
changes.push(...getChangesFromChordFile(file))
return changes
})
break
changes.update((changes) => {
changes.push(...getChangesFromChordFile(file));
return changes;
});
break;
}
case "layout": {
changes.update(changes => {
changes.push(...getChangesFromLayoutFile(file))
return changes
})
break
changes.update((changes) => {
changes.push(...getChangesFromLayoutFile(file));
return changes;
});
break;
}
case "settings": {
changes.update(changes => {
changes.push(...getChangesFromSettingsFile(file))
return changes
})
break
changes.update((changes) => {
changes.push(...getChangesFromSettingsFile(file));
return changes;
});
break;
}
default: {
throw new Error(`Unknown backup type "${(file as CharaFile<string>).type}"`)
throw new Error(
`Unknown backup type "${(file as CharaFile<string>).type}"`,
);
}
}
}
export function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = []
const existingChords = new Set(get(chords).map(({phrase, actions}) => JSON.stringify([actions, phrase])))
const changes: Change[] = [];
const existingChords = new Set(
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
);
for (const [input, output] of file.chords) {
if (existingChords.has(JSON.stringify([input, output]))) {
continue
continue;
}
changes.push({
type: ChangeType.Chord,
actions: input,
phrase: output,
id: input,
})
});
}
return changes
return changes;
}
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = []
const changes: Change[] = [];
for (const [id, value] of file.settings.entries()) {
const setting = get(settings)[id]
const setting = get(settings)[id];
if (setting !== undefined && setting.value !== value) {
changes.push({
type: ChangeType.Setting,
id,
setting: value,
})
});
}
}
return changes
return changes;
}
export function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = []
const changes: Change[] = [];
for (const [layer, keys] of file.layout.entries()) {
for (const [id, action] of keys.entries()) {
if (get(layout)[layer][id].action !== action) {
if (get(layout)[layer]?.[id]?.action !== action) {
changes.push({
type: ChangeType.Layout,
layer,
id,
action,
})
});
}
}
}
return changes
return changes;
}

View File

@@ -1,7 +1,7 @@
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
import type {CharaChordFile} from "$lib/share/chara-file"
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]])
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]]);
export function csvChordsToJson(csv: string): CharaChordFile {
return {
@@ -10,19 +10,22 @@ export function csvChordsToJson(csv: string): CharaChordFile {
chords: csv
.trim()
.split("\n")
.map(line => {
const [input, output] = line.split(/,(?=[^,]*$)/, 2)
.map((line) => {
const [input, output] = line.split(/,(?=[^,]*$)/, 2);
return [
input.split("+").map(it => KEYMAP_IDS.get(it.trim())?.code ?? 0),
output
input!
.split("+")
.map((it) => KEYMAP_IDS.get(it.trim())?.code ?? 0)
.sort((a, b) => a - b),
output!
.trim()
.split("")
.map(it => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
]
.map((it) => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
];
}),
}
};
}
export function isCsvChords(csv: string): boolean {
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv)
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv);
}

View File

@@ -4,21 +4,24 @@
"device": "one",
"layout": [
[
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287,
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315,
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 293, 260, 296,
544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278, 357, 516, 519, 517, 518, 327, 336, 338, 335,
337, 328, 325, 322, 323, 324
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262,
288, 277, 298, 307, 264, 287, 268, 332, 311, 274, 286, 308, 329, 310, 280,
358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 314, 317, 316,
312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263,
293, 260, 296, 544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278,
357, 516, 519, 517, 518, 327, 336, 338, 335, 337, 328, 325, 322, 323, 324
],
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
]
}

View File

@@ -1,18 +1,18 @@
import {describe, expect, it} from "vitest"
import legacyLayout from "./legacy-layout.sample.csv?raw"
import legacyLayoutConverted from "./legacy-layout-converted.sample.json"
import {csvLayoutToJson, isCsvLayout} from "./legacy-layout"
import { describe, expect, it } from "vitest";
import legacyLayout from "./legacy-layout.sample.csv?raw";
import legacyLayoutConverted from "./legacy-layout-converted.sample.json";
import { csvLayoutToJson, isCsvLayout } from "./legacy-layout";
describe("legacy layout", () => {
it("should detect a legacy layout", () => {
expect(isCsvLayout(legacyLayout)).to.be.true
})
expect(isCsvLayout(legacyLayout)).to.be.true;
});
it("should not detect chord maps as layouts", () => {
expect(isCsvLayout("e + h + t,the")).to.be.false
})
expect(isCsvLayout("e + h + t,the")).to.be.false;
});
it("should convert legacy layouts", () => {
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted)
})
})
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted);
});
});

View File

@@ -1,25 +1,28 @@
import type {CharaLayoutFile} from "$lib/share/chara-file"
import type { CharaLayoutFile } from "$lib/share/chara-file";
/**
* Converts a legacy CSV-based layout to the modern JSON-based format
*/
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile {
export function csvLayoutToJson(
csv: string,
device: CharaLayoutFile["device"] = "one",
): CharaLayoutFile {
const layout: CharaLayoutFile = {
charaVersion: 1,
type: "layout",
device,
layout: [[], [], []],
}
};
for (const layer of csv.trim().split("\n")) {
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
const [layerId, key, action] = layer.substring(1).split(",").map(Number);
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)
layout.layout[Number(layerId) - 1]![Number(key)] = Number(action);
}
return layout
return layout;
}
export function isCsvLayout(csv: string): boolean {
return /^(A[123],\d+,\d+\n?)+$/.test(csv)
return /^(A[123],\d+,\d+\n?)+$/.test(csv);
}

View File

@@ -1,24 +1,32 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import {action as title} from "$lib/title"
import {osLayout} from "$lib/os-layout"
import LL from "../../i18n/i18n-svelte"
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout";
import LL from "../../i18n/i18n-svelte";
export let action: number | KeyInfo
export let display: "inline-keys" | "keys" = "inline-keys"
export let action: number | KeyInfo;
export let display: "inline-keys" | "keys" = "inline-keys";
$: info = typeof action === "number" ? KEYMAP_CODES[action] ?? {code: action} : action
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode)
$: info =
typeof action === "number"
? KEYMAP_CODES.get(action) ?? { code: action }
: action;
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
$: tooltip =
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
(info.variant === "left" ? " (left)" : info.variant === "right" ? " (right)" : "")
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: "");
</script>
{#if dynamicMapping}
<span
use:title={{title: $LL.actionSearch.LIVE_LAYOUT_INFO()}}
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
class="dynamic"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
@@ -29,22 +37,28 @@
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{title: tooltip}}
use:title={{ title: tooltip }}
>
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
</kbd>
{:else if display === "inline-keys"}
{#if !info.icon && info.id?.length === 1}
<span class:left={info.variant === "left"} class:right={info.variant === "right"}>{info.id}</span>
<span
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span
>
{:else}
<kbd
class="inline-kbd"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{title: tooltip}}
use:title={{ title: tooltip }}
>
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}</kbd
{info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
>
{/if}
{/if}

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import LL from "../../i18n/i18n-svelte"
import Action from "$lib/components/Action.svelte"
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import LL from "../../i18n/i18n-svelte";
import Action from "$lib/components/Action.svelte";
export let id: number | KeyInfo
export let id: number | KeyInfo;
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
$: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
| number
| KeyInfo;
</script>
<button on:click>
@@ -23,10 +25,10 @@
{#if key.description}
<i>{key.description}</i>
{/if}
{#if key.category.name === "ASCII Macros"}
{#if key.category?.name === "ASCII Macros"}
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
{/if}
{#if key.category.name === "CP-1252"}
{#if key.category?.name === "CP-1252"}
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
{/if}
</div>
@@ -48,16 +50,28 @@
padding: 8px;
font-family: "Noto Sans Mono", monospace;
color: inherit;
background: transparent;
border: none;
border-radius: 8px;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
@media not (forced-colors: active) {
color: inherit;
background: transparent;
border: none;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px;
&:hover {
color: ActiveText;
}
}
}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import Action from "$lib/components/Action.svelte"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import Action from "$lib/components/Action.svelte";
import type { KeyInfo } from "$lib/serial/keymap-codes";
export let actions: Array<number | KeyInfo>
export let display: "keys" | "inline-keys" = "inline-keys"
export let actions: Array<number | KeyInfo>;
export let display: "keys" | "inline-keys" = "inline-keys";
</script>
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}

View File

@@ -1,7 +1,8 @@
<script>
import {useRegisterSW} from "virtual:pwa-register/svelte"
// @ts-expect-error no types here
import { useRegisterSW } from "virtual:pwa-register/svelte";
const {needRefresh, updateServiceWorker, offlineReady} = useRegisterSW()
const { needRefresh, updateServiceWorker, offlineReady } = useRegisterSW();
</script>
{#if $needRefresh}

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import {serialLog, serialPort} from "$lib/serial/connection"
import {slide} from "svelte/transition"
import { serialLog, serialPort } from "$lib/serial/connection";
import { slide } from "svelte/transition";
function submit(event: Event) {
event.preventDefault()
$serialPort.send(value.trim())
value = ""
io.scrollTo({top: io.scrollHeight})
event.preventDefault();
$serialPort?.send(0, value.trim());
value = "";
io.scrollTo({ top: io.scrollHeight });
}
let value: string
let io: HTMLDivElement
let value: string;
let io: HTMLDivElement;
</script>
<form on:submit={submit}>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
export let title: string | undefined
export let shortcut: string | undefined
export let title: string | undefined;
export let shortcut: string | undefined;
</script>
{#if title}

View File

@@ -1,73 +1,80 @@
<script lang="ts">
import {KEYMAP_CATEGORIES, KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import {createEventDispatcher} from "svelte"
import ActionListItem from "$lib/components/ActionListItem.svelte"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
import {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { createEventDispatcher, onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title";
export let currentAction: number | undefined = undefined
export let nextAction: number | undefined = undefined
export let currentAction: number | undefined = undefined;
export let nextAction: number | undefined = undefined;
const index = new Index({tokenize: "full"})
for (const action of Object.values(KEYMAP_CODES)) {
index?.add(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
)
onMount(() => {
searchBox.focus();
});
const index = new FlexSearch.Index({ tokenize: "full" });
createIndex();
async function createIndex() {
for (const [, action] of KEYMAP_CODES) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
Object.values(KEYMAP_CODES)
.filter(it => !!it.id)
.map(it => [it.id, it] as const),
)
function search() {
results = index!.search(searchBox.value)
exact = exactIndex[searchBox.value]?.code
code = Number(searchBox.value)
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
exact = KEYMAP_IDS.get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
dispatch("select", id)
dispatch("select", id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact)
dispatch("select", exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)")
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
element.querySelector("button")?.focus();
}
} else {
searchBox.focus()
return
searchBox.focus();
return;
}
event.preventDefault()
event.preventDefault();
}
let results: number[] = Object.keys(KEYMAP_CODES).map(Number)
let exact: number | undefined = undefined
let code: number = Number.NaN
let results: number[] = [];
let exact: number | undefined = undefined;
let code: number = Number.NaN;
const dispatch = createEventDispatcher()
let searchBox: HTMLInputElement
let resultList: HTMLUListElement
let filter: Set<number>
const dispatch = createEventDispatcher();
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter: Set<number>;
</script>
<svelte:window on:keydown={keyboardNavigation} />
@@ -80,18 +87,18 @@
type="search"
bind:this={searchBox}
on:input={search}
on:keypress={event => {
on:keypress={(event) => {
if (event.key === "Enter") {
select(exact)
select(exact);
}
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button on:click={() => select(0)} use:action={{shortcut: "shift+esc"}}
<button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{title: $LL.modal.CLOSE(), shortcut: "esc"}}
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
on:click={() => dispatch("close")}>close</button
>
@@ -143,9 +150,15 @@
<li>Action code is out of range</li>
{/if}
{/if}
{#each filter ? results.filter(it => filter.has(it)) : results as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from(KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
{/if}
</ul>
</div>
</dialog>
@@ -186,6 +199,7 @@
height: 100%;
background: rgba(0 0 0 / 60%);
border: none;
}
@@ -207,6 +221,15 @@
background: var(--md-sys-color-background);
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
@@ -231,6 +254,10 @@
background: var(--md-sys-color-background);
border-radius: 16px;
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
@@ -298,5 +325,9 @@
background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px;
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -1,177 +1,192 @@
<script lang="ts">
import {compileLayout} from "$lib/serialization/visual-layout"
import type {VisualLayout, CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {deviceLayout} from "$lib/serial/connection"
import {dev} from "$app/environment"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {get} from "svelte/store"
import type {Writable} from "svelte/store"
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"
import {getContext} from "svelte"
import type {VisualLayoutConfig} from "./visual-layout.js"
import {changes, ChangeType, layout} from "$lib/undo-redo"
import { compileLayout } from "$lib/serialization/visual-layout";
import type {
VisualLayout,
CompiledLayoutKey,
} from "$lib/serialization/visual-layout";
import { deviceLayout } from "$lib/serial/connection";
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
const {scale, margin, strokeWidth, fontSize, iconFontSize} =
getContext<VisualLayoutConfig>("visual-layout-config")
const activeLayer = getContext<Writable<number>>("active-layer")
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
if (dev) {
// you have absolutely no idea what a difference this makes for performance
console.assert(scale % 1 === 0, "Scale must be an integer")
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2")
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer")
console.assert(margin % 1 === 0, "Margin must be an integer")
console.assert(fontSize % 1 === 0, "Font size must be an integer")
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer")
console.assert(scale % 1 === 0, "Scale must be an integer");
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2");
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer");
console.assert(margin % 1 === 0, "Margin must be an integer");
console.assert(fontSize % 1 === 0, "Font size must be an integer");
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
}
export let visualLayout: VisualLayout
$: layoutInfo = compileLayout(visualLayout)
export let visualLayout: VisualLayout;
$: layoutInfo = compileLayout(visualLayout);
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
}
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
const x1 = a.pos[0] + margin
const y1 = a.pos[1] + margin
const x1b = x1 + a.size[0] - margin
const y1b = y1 + a.size[1] - margin
const x2 = b.pos[0] + margin
const y2 = b.pos[1] + margin
const x2b = x2 + b.size[0] - margin
const y2b = y2 + b.size[1] - margin
const x1 = a.pos[0] + margin;
const y1 = a.pos[1] + margin;
const x1b = x1 + a.size[0] - margin;
const y1b = y1 + a.size[1] - margin;
const x2 = b.pos[0] + margin;
const y2 = b.pos[1] + margin;
const x2b = x2 + b.size[0] - margin;
const y2b = y2 + b.size[1] - margin;
const left = x2b < x1
const right = x1b < x2
const bottom = y2b < y1
const top = y1b < y2
const left = x2b < x1;
const right = x1b < x2;
const bottom = y2b < y1;
const top = y1b < y2;
return top && left
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
: left && bottom
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
: bottom && right
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
: right && top
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
: left
? x1 - x2b
: right
? x2 - x1b
: bottom
? y1 - y2b
: top
? y2 - y1b
: 0
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
: bottom && right
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
: right && top
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
: left
? x1 - x2b
: right
? x2 - x1b
: bottom
? y1 - y2b
: top
? y2 - y1b
: 0;
}
function navigate(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey)
return;
let wantedAngle: number
const angleThreshold = Math.PI
let wantedAngle: number;
const angleThreshold = Math.PI;
if (event.key === "ArrowUp") wantedAngle = Math.PI
else if (event.key === "ArrowDown") wantedAngle = 0
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2
else return
if (event.key === "ArrowUp") wantedAngle = Math.PI;
else if (event.key === "ArrowDown") wantedAngle = 0;
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2;
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2;
else return;
event.preventDefault()
if (!focusKey) (groupParent.firstChild as SVGGElement).focus()
const [focusX, focusY] = getCenter(focusKey)
event.preventDefault();
if (!focusKey) (groupParent.firstChild as SVGGElement).focus();
const [focusX, focusY] = getCenter(focusKey);
let bestDistance = Infinity
let bestCandidate = 0
let isOptimalAngle = false
let bestDistance = Infinity;
let bestCandidate = 0;
let isOptimalAngle = false;
for (const [i, key] of layoutInfo.keys.entries()) {
if (key === focusKey) continue
const [keyX, keyY] = getCenter(key)
const deltaX = keyX - focusX
const deltaY = keyY - focusY
const angle = Math.atan2(deltaX, deltaY)
const distance = getDistance(key, focusKey)
if (key === focusKey) continue;
const [keyX, keyY] = getCenter(key);
const deltaX = keyX - focusX;
const deltaY = keyY - focusY;
const angle = Math.atan2(deltaX, deltaY);
const distance = getDistance(key, focusKey);
const angleDelta = Math.abs(wantedAngle - angle)
const angleDelta = Math.abs(wantedAngle - angle);
if (isOptimalAngle ? angleDelta > Number.EPSILON : angleDelta >= angleThreshold) continue
if (distance > bestDistance) continue
if (
isOptimalAngle
? angleDelta > Number.EPSILON
: angleDelta >= angleThreshold
)
continue;
if (distance > bestDistance) continue;
bestDistance = distance
bestCandidate = i
isOptimalAngle = angleDelta <= Number.EPSILON
bestDistance = distance;
bestCandidate = i;
isOptimalAngle = angleDelta <= Number.EPSILON;
}
const node = groupParent.children.item(bestCandidate)
const node = groupParent.children.item(bestCandidate);
if (node instanceof SVGGElement) {
node.focus()
node.focus();
}
}
function edit(index: number) {
const keyInfo = layoutInfo.keys[index]
const clickedGroup = groupParent.children.item(index) as SVGGElement
const nextAction = get(layout)[get(activeLayer)][keyInfo.id]
const currentAction = get(deviceLayout)[get(activeLayer)][keyInfo.id]
const keyInfo = layoutInfo.keys[index];
if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
const component = new ActionSelector({
target: document.body,
props: {
currentAction,
nextAction: nextAction.isApplied ? undefined : nextAction.action,
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
},
})
const dialog = document.querySelector("dialog > div") as HTMLDivElement
const backdrop = document.querySelector("dialog") as HTMLDialogElement
const dialogRect = dialog.getBoundingClientRect()
const groupRect = clickedGroup.getBoundingClientRect()
});
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect();
const groupRect = clickedGroup.getBoundingClientRect();
const scale = 0.5
const scale = 0.5;
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
1 - scale * (1 - groupRect.height / dialogRect.height)
}`
}`;
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y)
}px`
}px`;
const duration = 150
const options = {duration, easing: "ease"}
const duration = 150;
const options = { duration, easing: "ease" };
const dialogAnimation = dialog.animate(
[
{scale: dialogScale, translate: dialogTranslate},
{translate: "0 0", scale: "1"},
{ scale: dialogScale, translate: dialogTranslate },
{ translate: "0 0", scale: "1" },
],
options,
)
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options)
);
const backdropAnimation = backdrop.animate(
[{ opacity: 0 }, { opacity: 1 }],
options,
);
async function closed() {
dialogAnimation.reverse()
backdropAnimation.reverse()
dialogAnimation.reverse();
backdropAnimation.reverse();
await dialogAnimation.finished
await dialogAnimation.finished;
component.$destroy()
component.$destroy();
}
component.$on("close", closed)
component.$on("select", ({detail}) => {
changes.update(changes => {
component.$on("close", closed);
component.$on("select", ({ detail }) => {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action: detail,
})
return changes
})
closed()
})
});
return changes;
});
closed();
});
}
let focusKey: CompiledLayoutKey
let groupParent: SVGElement
let focusKey: CompiledLayoutKey;
let groupParent: SVGElement;
</script>
<svelte:window on:keydown={navigate} />
@@ -180,6 +195,7 @@
class="print"
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
bind:this={groupParent}
transition:fly={{ y: 48, easing: expoOut }}
>
{#each layoutInfo.keys as key, i}
<KeyboardKey
@@ -187,9 +203,9 @@
{key}
on:focusin={() => (focusKey = key)}
on:click={() => edit(i)}
on:keypress={({key}) => {
on:keypress={({ key }) => {
if (key === "Enter") {
edit(i)
edit(i);
}
}}
/>

View File

@@ -1,30 +1,34 @@
<script lang="ts">
import {getContext} from "svelte"
import type {Writable} from "svelte/store"
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout"
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {layout} from "$lib/undo-redo.js"
import {osLayout} from "$lib/os-layout.js"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import {action} from "$lib/title"
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import { layout } from "$lib/undo-redo.js";
import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title";
const {fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize} =
getContext<VisualLayoutConfig>("visual-layout-config")
const activeLayer = getContext<Writable<number>>("active-layer")
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
export let key: CompiledLayoutKey
export let fontSizeMultiplier = 1
export let key: CompiledLayoutKey;
export let fontSizeMultiplier = 1;
export let middle: [number, number]
export let pos: [number, number]
export let rotate: number
export let middle: [number, number];
export let pos: [number, number];
export let rotate: number;
export let positions: [[number, number], [number, number], [number, number]]
export let positions: [[number, number], [number, number], [number, number]];
</script>
{#each positions as position, layer}
{@const {action: actionId, isApplied} = $layout[layer][key.id] ?? {action: 0, isApplied: true}}
{@const {code, icon, id, display, title, keyCode, variant} = KEYMAP_CODES[actionId] ?? {code: actionId}}
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
action: 0,
isApplied: true,
}}
{@const { code, icon, id, display, title, keyCode, variant } =
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) +
@@ -44,13 +48,13 @@
y={pos[1] + middle[1]}
font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
font-family={hasIcon ? "Material Symbols Rounded" : undefined}
opacity={isActive ? 1 : inactiveOpacity}
style:scale={isActive ? 1 : inactiveScale}
opacity={isActive ? 1 : `var(--inactive-opacity, ${inactiveOpacity})`}
style:scale={isActive ? 1 : `var(--inactive-scale, ${inactiveScale})`}
style:translate={isActive
? "0 0 0"
: `${direction[0].toPrecision(2)}px ${direction[1].toPrecision(2)}px 0`}
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
style:rotate="{rotate}deg"
use:action={{title: tooltip}}
use:action={{ title: tooltip }}
>
{#if code !== 0}
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
@@ -75,6 +79,11 @@
opacity #{$transition} ease,
translate #{$transition} ease,
scale #{$transition} ease;
@media (prefers-contrast: more) {
--inactive-opacity: 0.8;
--inactive-scale: 0.7;
}
}
text:focus-within {

View File

@@ -1,20 +1,29 @@
<script lang="ts">
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {getContext} from "svelte"
import type {VisualLayoutConfig} from "./visual-layout.js"
import KeyText from "$lib/components/layout/KeyText.svelte"
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte";
const {scale, margin, strokeWidth} = getContext<VisualLayoutConfig>("visual-layout-config")
export let i: number
export let key: CompiledLayoutKey
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config",
);
export let i: number;
export let key: CompiledLayoutKey;
$: posX = key.pos[0] * scale
$: posY = key.pos[1] * scale
$: sizeX = key.size[0] * scale
$: sizeY = key.size[1] * scale
$: posX = key.pos[0] * scale;
$: posY = key.pos[1] * scale;
$: sizeX = key.size[0] * scale;
$: sizeY = key.size[1] * scale;
</script>
<g class="key-group" on:click on:keypress on:focusin role="button" tabindex={i + 1}>
<g
class="key-group"
on:click
on:keypress
on:focusin
role="button"
tabindex={i + 1}
>
{#if key.shape === "square"}
<rect
x={posX + margin}
@@ -44,15 +53,23 @@
{@const multiplier = 1.25}
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
{@const rotX = Math.round((Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
{@const rotY = Math.round((Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
{@const rotX =
Math.round(
(Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
) / 100}
{@const rotY =
Math.round(
(Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
) / 100}
{@const rc = r1 - (r1 - r2) / 2}
{@const middleX = Math.cos(rotateRad) * rc}
{@const middleY = Math.sin(rotateRad) * rc}
<path
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(p1 - p2)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(
p1 - p2
)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
/>
<KeyText
{key}

View File

@@ -1,44 +1,56 @@
<script lang="ts">
import {serialPort} from "$lib/serial/connection"
import {action} from "$lib/title"
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
import {getContext} from "svelte"
import type {Writable} from "svelte/store"
import type {VisualLayout} from "$lib/serialization/visual-layout"
import { serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
$: device = $serialPort?.device ?? "ONE"
const activeLayer = getContext<Writable<number>>("active-layer")
$: device = $serialPort?.device;
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const
] as const;
const layouts = {
ONE: () => import("$lib/assets/layouts/one.yml").then(it => it.default as VisualLayout),
LITE: () => import("$lib/assets/layouts/lite.yml").then(it => it.default as VisualLayout),
X: () => import("$lib/assets/layouts/generic/103-key.yml").then(it => it.default as VisualLayout),
}
ONE: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
),
LITE: () =>
import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as VisualLayout,
),
X: () =>
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
<div class="container">
<fieldset>
{#each layers as [title, icon, value]}
<button
class="icon"
use:action={{title, shortcut: `alt+${value + 1}`}}
on:click={() => ($activeLayer = value)}
class:active={$activeLayer === value}
>
{icon}
</button>
{/each}
</fieldset>
{#if device}
{#await layouts[device]() then visualLayout}
<fieldset transition:fade>
{#each layers as [title, icon, value]}
<button
class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }}
on:click={() => ($activeLayer = value)}
class:active={$activeLayer === value}
>
{icon}
</button>
{/each}
</fieldset>
{#await layouts[device]() then visualLayout}
<GenericLayout {visualLayout} />
{/await}
<GenericLayout {visualLayout} />
{/await}
{/if}
</div>
<style lang="scss">
@@ -60,7 +72,7 @@
align-items: center;
justify-content: center;
padding: 0;
padding: 8px;
border: none;
}
@@ -86,7 +98,6 @@
font-size: 32px;
border-radius: 50%;
outline: 8px solid var(--md-sys-color-background);
}
&:first-child,
@@ -96,12 +107,14 @@
}
&:first-child {
padding-inline: 4px 16px;
margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px;
}
&:last-child {
padding-inline: 16px 4px;
margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0;
}

View File

@@ -1,9 +1,9 @@
export interface VisualLayoutConfig {
scale: number
inactiveScale: number
inactiveOpacity: number
strokeWidth: number
margin: number
fontSize: number
iconFontSize: number
scale: number;
inactiveScale: number;
inactiveOpacity: number;
strokeWidth: number;
margin: number;
fontSize: number;
iconFontSize: number;
}

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import {createEventDispatcher} from "svelte"
import Dialog from "$lib/dialogs/Dialog.svelte"
import { createEventDispatcher } from "svelte";
import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte";
export let title: string
export let message: string | undefined
export let abortTitle: string
export let confirmTitle: string
export let title: string;
export let message: string | undefined;
export let abortTitle: string;
export let confirmTitle: string;
const dispatch = createEventDispatcher()
export let actions: number[] = [];
const dispatch = createEventDispatcher();
</script>
<Dialog>
@@ -15,9 +18,12 @@
{#if message}
<p>{@html message}</p>
{/if}
<p><ActionString {actions} /></p>
<div class="buttons">
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}>{confirmTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}
>{confirmTitle}</button
>
</div>
</Dialog>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import {onMount} from "svelte"
import { onMount } from "svelte";
onMount(() => {
modal.showModal()
})
modal.showModal();
});
let modal: HTMLDialogElement
let modal: HTMLDialogElement;
</script>
<dialog bind:this={modal}>

View File

@@ -1,87 +1,139 @@
<script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte"
import type {Change, ChordChange, LayoutChange, SettingChange} from "$lib/undo-redo"
import {ChangeType, chords} from "$lib/undo-redo"
import ActionString from "$lib/components/ActionString.svelte"
import LL from "../../i18n/i18n-svelte"
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
import Dialog from "$lib/dialogs/Dialog.svelte";
import type {
Change,
ChordChange,
LayoutChange,
SettingChange,
} from "$lib/undo-redo";
import { ChangeType, chords } from "$lib/undo-redo";
import ActionString from "$lib/components/ActionString.svelte";
import LL from "../../i18n/i18n-svelte";
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
export let changes: Change[] = [
{type: ChangeType.Layout, layer: 0, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Chord, id: [1], actions: [55], phrase: [55, 63, 37, 36]},
{ type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{
type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
id: [1],
actions: [55],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
id: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [],
},
]
];
$: existingChords = new Set($chords.map(it => JSON.stringify(it.id)))
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
$: layoutChanges = Array.from(
{length: 3},
(_, i) => changes.filter(it => it.type === ChangeType.Layout && it.layer === i) as LayoutChange[],
)
$: settingChanges = changes.filter(it => it.type === ChangeType.Setting) as SettingChange[]
{ length: 3 },
(_, i) =>
changes.filter(
(it) => it.type === ChangeType.Layout && it.layer === i,
) as LayoutChange[],
);
$: settingChanges = changes.filter(
(it) => it.type === ChangeType.Setting,
) as SettingChange[];
$: chordChanges = {
added: changes.filter(
it =>
it.type === ChangeType.Chord && it.phrase.length > 0 && !existingChords.has(JSON.stringify(it.id)),
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
!existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
changed: changes.filter(
it => it.type === ChangeType.Chord && it.phrase.length > 0 && existingChords.has(JSON.stringify(it.id)),
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
deleted: changes.filter(it => it.type === ChangeType.Chord && it.phrase.length === 0) as ChordChange[],
}
$: totalChordChanges = Object.values(chordChanges).reduce((acc, curr) => acc + curr.length, 0)
deleted: changes.filter(
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
) as ChordChange[],
};
$: totalChordChanges = Object.values(chordChanges).reduce(
(acc, curr) => acc + curr.length,
0,
);
</script>
<Dialog>
<h1>{$LL.changes.TITLE()}</h1>
<h2>
<label><input type="checkbox" class="checkbox" />{$LL.changes.ALL_CHANGES()}</label>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.ALL_CHANGES()}</label
>
</h2>
<ul>
{#if layoutChanges.some(it => it.length > 0)}
{#if layoutChanges.some((it) => it.length > 0)}
<li>
<h3>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.TITLE(layoutChanges.reduce((acc, curr) => acc + curr.length, 0))}
{$LL.changes.layout.TITLE(
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
)}
</label>
</h3>
<ul>
{#each layoutChanges
.map((it, i) => /** @type {const} */ ([it, i + 1]))
.filter(([it]) => it.length > 0) as [changes, layer]}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({changes: changes.length, layer})}
</label>
</h4>
</li>
{#each layoutChanges as changes, i}
{@const layer = i + 1}
{#if changes.length > 0}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({
changes: changes.length,
layer,
})}
</label>
</h4>
</li>
{/if}
{/each}
</ul>
</li>
@@ -90,9 +142,10 @@
<li>
<h3>
<label
><input type="checkbox" class="checkbox" />{$LL.changes.settings.TITLE(
settingChanges.length,
)}</label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
>
</h3>
</li>
@@ -101,7 +154,10 @@
<li>
<h3>
<label
><input type="checkbox" class="checkbox" />{$LL.changes.chords.TITLE(totalChordChanges)}</label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
>
</h3>
<ul>

View File

@@ -1,10 +1,11 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte"
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
export async function askForConfirmation(
title: string,
message: string,
confirmTitle: string,
abortTitle: string,
actions: number[],
): Promise<boolean> {
const dialog = new ConfirmDialog({
target: document.body,
@@ -13,19 +14,20 @@ export async function askForConfirmation(
message,
confirmTitle,
abortTitle,
actions,
},
})
});
let resolvePromise: (value: boolean) => void
const resultPromise = new Promise<boolean>(resolve => {
resolvePromise = resolve
})
let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
dialog.$on("abort", () => resolvePromise(false))
dialog.$on("confirm", () => resolvePromise(true))
dialog.$on("abort", () => resolvePromise(false));
dialog.$on("confirm", () => resolvePromise(true));
const result = await resultPromise
dialog.$destroy()
const result = await resultPromise;
dialog.$destroy();
return result
return result;
}

View File

@@ -73,8 +73,9 @@
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations");
unicode-range: 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;
unicode-range: 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;
}
/* noto-sans-mono-latin-wght-normal */
@@ -86,7 +87,7 @@
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, 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;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, 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;
}

View File

@@ -1,25 +1,27 @@
import {get, writable} from "svelte/store"
import { get, writable } from "svelte/store";
export const osLayout = writable<Map<string, string>>(new Map())
export const osLayout = writable<Map<string, string>>(new Map());
async function updateLayout() {
const layout: Map<string, string> = await (navigator as any).keyboard.getLayoutMap()
const currentLayout = get(osLayout)
const layout: Map<string, string> = await (
navigator as any
).keyboard.getLayoutMap();
const currentLayout = get(osLayout);
if (
layout.size !== currentLayout.size ||
[...layout.keys()].some(key => layout.get(key) !== currentLayout.get(key))
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
) {
osLayout.set(layout)
osLayout.set(layout);
}
}
export function runLayoutDetection(): () => void {
if ("keyboard" in navigator) {
updateLayout()
const timer = setInterval(updateLayout, 5000)
return () => clearInterval(timer)
updateLayout();
const timer = setInterval(updateLayout, 5000);
return () => clearInterval(timer);
} else {
console.warn("Keyboard API not supported")
return () => {}
console.warn("Keyboard API not supported");
return () => {};
}
}

View File

@@ -1,28 +1,31 @@
import tippy from "tippy.js"
import type {Action} from "svelte/action"
import type {ComponentType, SvelteComponent} from "svelte"
import tippy from "tippy.js";
import type { Action } from "svelte/action";
import type { ComponentType, SvelteComponent } from "svelte";
export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component) => {
let component: SvelteComponent | undefined
let target: HTMLElement | undefined
export const popup: Action<HTMLButtonElement, ComponentType> = (
node,
Component,
) => {
let component: SvelteComponent | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, {
interactive: true,
trigger: "click",
onShow(instance) {
target = instance.popper.querySelector(".tippy-content") as HTMLElement
target.classList.add("active")
component ??= new Component({target})
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active");
component ??= new Component({ target });
},
onHidden() {
component?.$destroy()
target?.classList.remove("active")
component = undefined
component?.$destroy();
target?.classList.remove("active");
component = undefined;
},
})
});
return {
destroy() {
edit.destroy()
edit.destroy();
},
}
}
};
};

View File

@@ -1,37 +1,43 @@
import type { Action } from "svelte/action"
import { persistentWritable } from "$lib/storage"
import type { Action } from "svelte/action";
import { persistentWritable } from "$lib/storage";
export interface UserPreferences {
backup: boolean
autoConnect: boolean
backup: boolean;
autoConnect: boolean;
}
export const theme = persistentWritable("user-theme", {
color: "#6D81C7",
mode: "dark" as "light" | "dark" | "auto",
})
});
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", {
backup: false,
autoConnect: false,
})
export const userPreferences = persistentWritable<UserPreferences>(
"user-preferences",
{
backup: false,
autoConnect: false,
},
);
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => {
const unsubscribe = userPreferences.subscribe(it => {
node.checked = it[key]
})
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (
node,
key,
) => {
const unsubscribe = userPreferences.subscribe((it) => {
node.checked = it[key];
});
function update() {
userPreferences.update(value => {
value[key] = node.checked
return value
})
userPreferences.update((value) => {
value[key] = node.checked;
return value;
});
}
node.addEventListener("input", update)
node.addEventListener("input", update);
return {
destroy() {
unsubscribe()
node.removeEventListener("input", update)
unsubscribe();
node.removeEventListener("input", update);
},
}
}
};
};

View File

@@ -1,15 +1,22 @@
<script lang="ts">
import {createEventDispatcher} from "svelte"
import { createEventDispatcher } from "svelte";
export let ports: SerialPort[]
const dispatch = createEventDispatcher<{confirm: SerialPort | undefined}>()
let selected = ports[0].getInfo().name
export let ports: SerialPort[];
const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
let selected = ports[0]?.getInfo().name;
</script>
<dialog>
{#each ports as port}
{@const info = port.getInfo()}
<label>{info.product}<input type="radio" name="port" value={info.name} bind:group={selected} /></label>
<label
>{info.product}<input
type="radio"
name="port"
value={info.name}
bind:group={selected}
/></label
>
{/each}
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
@@ -17,7 +24,7 @@
on:click={() =>
dispatch(
"confirm",
ports.find(it => it.getInfo().name === selected),
ports.find((it) => it.getInfo().name === selected),
)}>Ok</button
>
</dialog>

View File

@@ -1,4 +1,4 @@
import {describe, it, expect} from "vitest"
import { describe, it, expect } from "vitest";
import {
deserializeActions,
parseChordActions,
@@ -6,43 +6,55 @@ import {
serializeActions,
stringifyChordActions,
stringifyPhrase,
} from "./chord"
} from "./chord";
describe("chords", function () {
describe("actions", function () {
it("should serialize actions", function () {
expect(serializeActions([32, 51]).toString(16)).toEqual(0xcc200000000000000000000000000n.toString(16))
})
expect(serializeActions([32, 51]).toString(16)).toEqual(
0xcc200000000000000000000000000n.toString(16),
);
});
it("should deserialize actions", function () {
expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([32, 51])
})
expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([
32, 51,
]);
});
for (let i = 0; i < 12; i++) {
it(`should serialize back-forth ${i} actions`, function () {
const actions = Array.from({length: i}).map((_, i) => i + 1)
expect(deserializeActions(serializeActions(actions))).toEqual(actions)
})
const actions = Array.from({ length: i }).map((_, i) => i + 1);
expect(deserializeActions(serializeActions(actions))).toEqual(actions);
});
}
})
});
describe("phrase", function () {
it("should stringify", function () {
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual("206872D4651FFF")
})
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual(
"206872D4651FFF",
);
});
it("should parse", function () {
expect(parsePhrase("206872D4651FFF")).toEqual([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])
})
})
expect(parsePhrase("206872D4651FFF")).toEqual([
0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff,
]);
});
});
describe("chord actions", function () {
it("should stringify", function () {
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000")
})
expect(stringifyChordActions([32, 51])).toEqual(
"000CC200000000000000000000000000",
);
});
it("should parse", function () {
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51])
})
})
})
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([
32, 51,
]);
});
});
});

View File

@@ -1,31 +1,31 @@
import {compressActions, decompressActions} from "../serialization/actions"
import { compressActions, decompressActions } from "../serialization/actions";
export interface Chord {
actions: number[]
phrase: number[]
actions: number[];
phrase: number[];
}
export function parsePhrase(phrase: string): number[] {
return decompressActions(
Uint8Array.from({length: phrase.length / 2}).map((_, i) =>
Uint8Array.from({ length: phrase.length / 2 }).map((_, i) =>
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
),
)
);
}
export function stringifyPhrase(phrase: number[]): string {
return [...compressActions(phrase)]
.map(it => it.toString(16).padStart(2, "0"))
.map((it) => it.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
.toUpperCase();
}
export function parseChordActions(actions: string): number[] {
return deserializeActions(BigInt(`0x${actions}`))
return deserializeActions(BigInt(`0x${actions}`));
}
export function stringifyChordActions(actions: number[]): string {
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase()
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase();
}
/**
@@ -34,25 +34,24 @@ export function stringifyChordActions(actions: number[]): string {
* Actions are represented as 10-bit codes, for a maximum of 12 actions
*/
export function serializeActions(actions: number[]): bigint {
let native = 0n
let native = 0n;
for (let i = 1; i <= actions.length; i++) {
native |= BigInt(actions[actions.length - i] & 0x3ff) << BigInt((12 - i) * 10)
native |=
BigInt(actions[actions.length - i]! & 0x3ff) << BigInt((12 - i) * 10);
}
return native
return native;
}
/**
* @see {serializeActions}
*/
export function deserializeActions(native: bigint): number[] {
const actions = []
const actions = [];
for (let i = 0; i < 12; i++) {
const action = Number(native & 0x3ffn)
if (action !== 0) {
actions.push(action)
}
native >>= 10n
const action = Number(native & 0x3ffn);
actions.push(action);
native >>= 10n;
}
return actions
return actions;
}

View File

@@ -1,20 +1,20 @@
import {get, writable} from "svelte/store"
import {CharaDevice} from "$lib/serial/device"
import type {Chord} from "$lib/serial/chord"
import type {Writable} from "svelte/store"
import type {CharaLayout} from "$lib/serialization/layout"
import {persistentWritable} from "$lib/storage"
import {userPreferences} from "$lib/preferences"
import settingInfo from "$lib/assets/settings.yml"
import { get, writable } from "svelte/store";
import { CharaDevice } from "$lib/serial/device";
import type { Chord } from "$lib/serial/chord";
import type { Writable } from "svelte/store";
import type { CharaLayout } from "$lib/serialization/layout";
import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences";
import settingInfo from "$lib/assets/settings.yml";
export const serialPort = writable<CharaDevice | undefined>()
export const serialPort = writable<CharaDevice | undefined>();
export interface SerialLogEntry {
type: "input" | "output" | "system"
value: string
type: "input" | "output" | "system";
value: string;
}
export const serialLog = writable<SerialLogEntry[]>([])
export const serialLog = writable<SerialLogEntry[]>([]);
/**
* Chords as read from the device
@@ -23,7 +23,7 @@ export const deviceChords = persistentWritable<Chord[]>(
"chord-library",
[],
() => get(userPreferences).backup,
)
);
/**
* Layout as read from the device
@@ -32,7 +32,7 @@ export const deviceLayout = persistentWritable<CharaLayout>(
"layout",
[[], [], []],
() => get(userPreferences).backup,
)
);
/**
* Settings as read from the device
@@ -41,55 +41,68 @@ export const deviceSettings = persistentWritable<number[]>(
"device-settings",
[],
() => get(userPreferences).backup,
)
);
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export interface ProgressInfo {
max: number
current: number
max: number;
current: number;
}
export const syncProgress = writable<ProgressInfo | undefined>(undefined)
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice()
await device.init(manual)
serialPort.set(device)
const chordCount = await device.getChordCount()
syncStatus.set("downloading")
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
serialPort.set(device);
await sync();
}
const max = Object.keys(settingInfo.settings).length + device.keyCount * 3 + chordCount
let current = 0
syncProgress.set({max, current})
export async function sync() {
const device = get(serialPort);
if (!device) return;
const chordCount = await device.getChordCount();
syncStatus.set("downloading");
const max =
Object.keys(settingInfo["settings"]).length +
device.keyCount * 3 +
chordCount;
let current = 0;
syncProgress.set({ max, current });
function progressTick() {
current++
syncProgress.set({max, current})
current++;
syncProgress.set({ max, current });
}
const parsedSettings: number[] = []
for (const key in settingInfo.settings) {
const parsedSettings: number[] = [];
for (const key in settingInfo["settings"]) {
try {
parsedSettings[Number.parseInt(key)] = await device.getSetting(Number.parseInt(key))
parsedSettings[Number.parseInt(key)] = await device.getSetting(
Number.parseInt(key),
);
} catch {}
progressTick()
progressTick();
}
deviceSettings.set(parsedSettings)
deviceSettings.set(parsedSettings);
const parsedLayout: CharaLayout = [[], [], []]
const parsedLayout: CharaLayout = [[], [], []];
for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < device.keyCount; i++) {
parsedLayout[layer - 1][i] = await device.getLayoutKey(layer, i)
progressTick()
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
progressTick();
}
}
deviceLayout.set(parsedLayout)
deviceLayout.set(parsedLayout);
const chordInfo = []
const chordInfo = [];
for (let i = 0; i < chordCount; i++) {
chordInfo.push(await device.getChord(i))
progressTick()
chordInfo.push(await device.getChord(i));
progressTick();
}
deviceChords.set(chordInfo)
syncStatus.set("done")
syncProgress.set(undefined)
deviceChords.set(chordInfo);
syncStatus.set("done");
syncProgress.set(undefined);
}

View File

@@ -1,250 +1,343 @@
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
import {serialLog} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
import {SemVer} from "$lib/serial/sem-ver"
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
import {browser} from "$app/environment"
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
import { serialLog } from "$lib/serial/connection";
import type { Chord } from "$lib/serial/chord";
import { SemVer } from "$lib/serial/sem-ver";
import {
parseChordActions,
parsePhrase,
stringifyChordActions,
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", {usbProductId: 32783, usbVendorId: 9114}],
["LITE S2", {usbProductId: 33070, usbVendorId: 12346}],
["LITE M0", {usbProductId: 32796, usbVendorId: 9114}],
["X", {usbProductId: 33163, usbVendorId: 12346}],
])
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
]);
const KEY_COUNTS = {
ONE: 90,
LITE: 67,
X: 256,
} as const
} as const;
if (browser && navigator.serial === undefined && import.meta.env.TAURI_FAMILY !== undefined) {
await import("./tauri-serial")
if (
browser &&
navigator.serial === undefined &&
import.meta.env.TAURI_FAMILY !== undefined
) {
await import("./tauri-serial");
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then(ports =>
ports.filter(it => {
const {usbProductId, usbVendorId} = it.getInfo()
return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => {
const { usbProductId, usbVendorId } = it.getInfo();
for (const filter of PORT_FILTERS.values()) {
if (filter.usbProductId === usbProductId && filter.usbVendorId === usbVendorId) {
return true
if (
filter.usbProductId === usbProductId &&
filter.usbVendorId === usbVendorId
) {
return true;
}
}
return false
return false;
}),
)
);
}
type LengthArray<T, N extends number, R extends T[] = []> = number extends N
? T[]
: R["length"] extends N
? R
: LengthArray<T, N, [T, ...R]>;
export async function canAutoConnect() {
return getViablePorts().then(it => it.length === 1)
return getViablePorts().then((it) => it.length === 1);
}
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: number;
return Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Timeout")),
ms,
) as unknown as number;
}),
]).finally(() => clearTimeout(timer));
}
export class CharaDevice {
private port!: SerialPort
private reader!: ReadableStreamDefaultReader<string>
private port!: SerialPort;
private reader!: ReadableStreamDefaultReader<string>;
private readonly abortController1 = new AbortController()
private readonly abortController2 = new AbortController()
private readonly abortController1 = new AbortController();
private readonly abortController2 = new AbortController();
private streamClosed!: Promise<void>
private streamClosed!: Promise<void>;
private lock?: Promise<true>
private lock?: Promise<true>;
private readonly suspendDebounce = 100
private suspendDebounceId?: number
private readonly suspendDebounce = 100;
private suspendDebounceId?: number;
version!: SemVer
company!: "CHARACHORDER"
device!: "ONE" | "LITE" | "X"
chipset!: "M0" | "S2"
keyCount!: 90 | 67 | 256
version!: SemVer;
company!: "CHARACHORDER";
device!: "ONE" | "LITE" | "X";
chipset!: "M0" | "S2";
keyCount!: 90 | 67 | 256;
get portInfo() {
return this.port.getInfo()
return this.port.getInfo();
}
constructor(private readonly baudRate = 115200) {}
async init(manual = false) {
try {
const ports = await getViablePorts()
const ports = await getViablePorts();
this.port =
!manual && ports.length === 1
? ports[0]
: await navigator.serial.requestPort({filters: [...PORT_FILTERS.values()]})
? ports[0]!
: await navigator.serial.requestPort({
filters: [...PORT_FILTERS.values()],
});
await this.port.open({baudRate: this.baudRate})
const info = this.port.getInfo()
serialLog.update(it => {
await this.port.open({ baudRate: this.baudRate });
const info = this.port.getInfo();
serialLog.update((it) => {
it.push({
type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
value: `Connected; ID: 0x${info.usbProductId?.toString(
16,
)}`,
})
return it
})
await this.port.close()
)}; Vendor: 0x${info.usbVendorId?.toString(16)}`,
});
return it;
});
await this.port.close();
this.version = new SemVer(await this.send("VERSION").then(([version]) => version))
const [company, device, chipset] = await this.send("ID")
this.company = company as "CHARACHORDER"
this.device = device as "ONE" | "LITE" | "X"
this.chipset = chipset as "M0" | "S2"
this.keyCount = KEY_COUNTS[this.device]
this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version),
);
const [company, device, chipset] = await this.send(3, "ID");
this.company = company as "CHARACHORDER";
this.device = device as "ONE" | "LITE" | "X";
this.chipset = chipset as "M0" | "S2";
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(e)
console.error(e)
throw e
alert(e);
console.error(e);
throw e;
}
}
private async suspend() {
await this.reader.cancel()
await this.reader.cancel();
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
});
this.reader.releaseLock();
await this.port.close();
serialLog.update((it) => {
it.push({
type: "system",
value: "Connection suspended",
});
return it;
});
}
private async wake() {
await this.port.open({baudRate: this.baudRate})
const decoderStream = new TextDecoderStream()
await this.port.open({ baudRate: this.baudRate });
const decoderStream = new TextDecoderStream();
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal,
})
});
this.reader = decoderStream
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
signal: this.abortController2.signal,
})
.getReader()
.getReader();
serialLog.update((it) => {
it.push({
type: "system",
value: "Connection resumed",
});
return it;
});
}
private async internalRead() {
const {value} = await this.reader.read()
serialLog.update(it => {
it.push({
type: "output",
value: value!,
})
return it
})
return value!
try {
const { value } = await timeout(this.reader.read(), 5000);
serialLog.update((it) => {
it.push({
type: "output",
value: value!,
});
return it;
});
return value!;
} catch (e) {
serialLog.update((it) => {
it.push({
type: "output",
value: `${e}`,
});
return it;
});
}
return undefined;
}
/**
* Send a command to the device
*/
private async internalSend(...command: string[]) {
const writer = this.port.writable!.getWriter()
const writer = this.port.writable!.getWriter();
try {
serialLog.update(it => {
serialLog.update((it) => {
it.push({
type: "input",
value: command.join(" "),
})
return it
})
await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`))
});
return it;
});
await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`));
} finally {
writer.releaseLock()
writer.releaseLock();
}
}
async forget() {
await this.port.forget()
await this.port.forget();
}
/**
* Read/write to serial port
*/
async runWith<T>(
callback: (send: typeof this.internalSend, read: typeof this.internalRead) => T | Promise<T>,
callback: (
send: typeof this.internalSend,
read: typeof this.internalRead,
) => T | Promise<T>,
): Promise<T> {
while (this.lock) {
await this.lock
await this.lock;
}
const send = this.internalSend.bind(this)
const read = this.internalRead.bind(this)
const exec = new Promise<T>(async resolve => {
let result!: T
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId)
} else {
await this.wake()
}
result = await callback(send, read)
} finally {
delete this.lock
this.suspendDebounceId = setTimeout(() => {
// cannot be locked here as all the code until clearTimeout is sync
console.assert(this.lock === undefined)
this.lock = this.suspend().then(() => {
delete this.lock
delete this.suspendDebounceId
return true
})
}, this.suspendDebounce) as any
resolve(result)
const send = this.internalSend.bind(this);
const read = this.internalRead.bind(this);
let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => {
resolveLock = resolve;
});
let result!: T;
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId);
} else {
await this.wake();
}
})
this.lock = exec.then(() => true)
return exec
result = await callback(send, read);
} finally {
delete this.lock;
this.suspendDebounceId = setTimeout(() => {
// cannot be locked here as all the code until clearTimeout is sync
console.assert(this.lock === undefined);
this.lock = this.suspend().then(() => {
delete this.lock;
delete this.suspendDebounceId;
return true;
});
}, this.suspendDebounce) as any;
resolveLock!(true);
return result;
}
}
/**
* Send to serial port
*/
async send(...command: string[]) {
async send<T extends number>(
expectedLength: T,
...command: string[]
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => {
await send(...command)
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" "))
})
await send(...command);
const commandString = command
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read();
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
string,
T
>;
}
const array = readResult
.replace(new RegExp(`^${commandString} `), "")
.split(" ");
if (array.length < expectedLength) {
console.error("Response too short");
return array.concat(
Array(expectedLength - array.length).fill("TOO_SHORT"),
) as LengthArray<string, T>;
}
return array as LengthArray<string, T>;
});
}
async getChordCount(): Promise<number> {
const [count] = await this.send("CML C0")
return Number.parseInt(count)
const [count] = await this.send(1, "CML C0");
return Number.parseInt(count);
}
/**
* Retrieves a chord by index
*/
async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(`CML C1 ${index}`)
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
}
};
}
/**
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`)
return phrase === "2" ? undefined : parsePhrase(phrase)
const [phrase] = await this.send(
1,
`CML C2 ${stringifyChordActions(actions)}`,
);
return phrase === "2" ? undefined : parsePhrase(phrase);
}
async setChord(chord: Chord) {
const [status] = await this.send(
1,
"CML",
"C3",
stringifyChordActions(chord.actions),
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">) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
console.log(status)
if (status.at(-1) !== "2") throw new Error(`Failed with status ${status}`)
const status = await this.send(
1,
`CML C4 ${stringifyChordActions(chord.actions)}`,
);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
}
/**
@@ -254,9 +347,8 @@ export class CharaDevice {
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(`VAR B4 A${layer} ${id} ${action}`)
console.log(status)
if (status !== "0") throw new Error(`Failed with status ${status}`)
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
/**
@@ -266,9 +358,9 @@ export class CharaDevice {
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
return Number(position)
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position);
}
/**
@@ -279,8 +371,8 @@ export class CharaDevice {
* **This does not need to be called for chords**
*/
async commit() {
const [status] = await this.send("VAR B0")
if (status !== "0") throw new Error(`Failed with status ${status}`)
const [status] = await this.send(1, "VAR B0");
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
/**
@@ -290,39 +382,49 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(`VAR B2 ${id.toString(16).toUpperCase()} ${value}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
const [status] = await this.send(
1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
/**
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(`VAR B1 ${id.toString(16).toUpperCase()}`)
const [value, status] = await this.send(
2,
`VAR B1 ${id.toString(16).toUpperCase()}`,
);
if (status !== "0")
throw new Error(`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`)
return Number(value)
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
);
return Number(value);
}
/**
* Reboots the device
*/
async reboot() {
await this.send("RST")
await this.send(0, "RST");
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send("RST BOOTLOADER")
await this.send(0, "RST BOOTLOADER");
}
/**
* Resets the device
*/
async reset(type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC") {
await this.send(`RST ${type}`)
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, `RST ${type}`);
}
/**
@@ -331,6 +433,6 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue.
*/
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send("RAM"))
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
}
}

View File

@@ -1,39 +1,38 @@
import type {ActionInfo, KeymapCategory} from "$lib/assets/keymaps/keymap"
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
export interface KeyInfo extends Partial<ActionInfo> {
code: number
category: KeymapCategory
code: number;
category?: KeymapCategory;
}
export const KEYMAP_CATEGORIES = (await Promise.all(
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(async load =>
load().then(it => (it as any).default),
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
async (load) => load().then((it) => (it as any).default),
),
)) as KeymapCategory[]
)) as KeymapCategory[];
export const KEYMAP_CODES: Record<number, KeyInfo> = Object.fromEntries(
KEYMAP_CATEGORIES.flatMap(category =>
export const KEYMAP_CODES = new Map<number, KeyInfo>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(([code, action]) => [
Number(code),
{...action, code: Number(code), category},
{ ...action, code: Number(code), category },
]),
),
)
);
export const KEYMAP_KEYCODES: Map<string, number> = new Map(
KEYMAP_CATEGORIES.flatMap(category =>
Object.entries(category.actions).map(([code, action]) => [action.keyCode!, Number(code)] as const),
).filter(([keyCode]) => keyCode !== undefined),
)
export const KEYMAP_IDS: Map<string, KeyInfo> = new Map(
KEYMAP_CATEGORIES.flatMap(category =>
export const KEYMAP_KEYCODES = new Map<string, number>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) => [action.id!, {...action, code: Number(code), category}] as const,
([code, action]) => [action.keyCode!, Number(code)] as const,
),
).filter(([keyCode]) => keyCode !== undefined),
);
export const KEYMAP_IDS = new Map<string, KeyInfo>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) =>
[action.id!, { ...action, code: Number(code), category }] as const,
),
).filter(([id]) => id !== undefined),
)
export const specialKeycodes = new Map([
[" ", 32], // Space
])
);

View File

@@ -1,18 +1,18 @@
export class LineBreakTransformer {
private chunks = ""
private chunks = "";
// noinspection JSUnusedGlobalSymbols
transform(chunk: string, controller: TransformStreamDefaultController) {
this.chunks += chunk
const lines = this.chunks.split("\r\n")
this.chunks = lines.pop()!
this.chunks += chunk;
const lines = this.chunks.split("\r\n");
this.chunks = lines.pop()!;
for (const line of lines) {
controller.enqueue(line)
controller.enqueue(line);
}
}
// noinspection JSUnusedGlobalSymbols
flush(controller: TransformStreamDefaultController) {
controller.enqueue(this.chunks)
controller.enqueue(this.chunks);
}
}

View File

@@ -1,20 +1,25 @@
export class SemVer {
major: number
minor: number
patch: number
preRelease?: string
meta?: string
major = 0;
minor = 0;
patch = 0;
preRelease?: string;
meta?: string;
constructor(versionString: string) {
const [, major, minor, patch, preRelease, meta] =
const result =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
versionString,
)!
this.major = Number.parseInt(major)
this.minor = Number.parseInt(minor)
this.patch = Number.parseInt(patch)
if (preRelease) this.preRelease = preRelease
if (meta) this.meta = meta
);
if (!result) {
console.error("Invalid version string:", versionString);
} else {
const [, major, minor, patch, preRelease, meta] = result;
this.major = Number.parseInt(major ?? "NaN");
this.minor = Number.parseInt(minor ?? "NaN");
this.patch = Number.parseInt(patch ?? "NaN");
if (preRelease) this.preRelease = preRelease;
if (meta) this.meta = meta;
}
}
toString() {
@@ -22,6 +27,6 @@ export class SemVer {
`${this.major}.${this.minor}.${this.patch}` +
(this.preRelease ? `-${this.preRelease}` : "") +
(this.meta ? `+${this.meta}` : "")
)
);
}
}

View File

@@ -2,42 +2,53 @@
* Compress JSON.stringify with gzip
*/
export async function stringifyCompressed<T>(chords: T): Promise<Blob> {
const stream = new Blob([JSON.stringify(chords)]).stream().pipeThrough(new CompressionStream("gzip"))
return await new Response(stream).blob()
const stream = new Blob([JSON.stringify(chords)])
.stream()
.pipeThrough(new CompressionStream("gzip"));
return await new Response(stream).blob();
}
/**
* Decompress JSON.parse with gzip
*/
export async function parseCompressed<T>(blob: Blob): Promise<T> {
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"))
return await new Response(stream).json()
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"));
return await new Response(stream).json();
}
/**
* Share JS object as url query param
*/
export async function getSharableUrl(name: string, data: any, baseHref = window.location.href): Promise<URL> {
return new Promise(async resolve => {
const reader = new FileReader()
export async function getSharableUrl(
name: string,
data: any,
baseHref = window.location.href,
): Promise<URL> {
return new Promise(async (resolve) => {
const reader = new FileReader();
reader.onloadend = function () {
const base64String = (reader.result as string).replace(/^data:application\/octet-stream;base64,/, "")
const url = new URL(baseHref)
url.searchParams.set(name, base64String)
resolve(url)
}
reader.readAsDataURL(await stringifyCompressed(data))
})
const base64String = (reader.result as string).replace(
/^data:application\/octet-stream;base64,/,
"",
);
const url = new URL(baseHref);
url.searchParams.set(name, base64String);
resolve(url);
};
reader.readAsDataURL(await stringifyCompressed(data));
});
}
export async function parseSharableUrl<T>(
name: string,
url: string = window.location.href,
): Promise<T | undefined> {
const searchParams = new URL(url).searchParams
if (!searchParams.has(name)) return
const searchParams = new URL(url).searchParams;
if (!searchParams.has(name)) return;
return await fetch(`data:application/octet-stream;base64,${searchParams.get(name)}`)
.then(it => it.blob())
.then(it => parseCompressed(it))
return await fetch(
`data:application/octet-stream;base64,${searchParams.get(name)}`,
)
.then((it) => it.blob())
.then((it) => parseCompressed(it));
}

View File

@@ -1,8 +1,8 @@
/// <references types="@types/w3c-web-serial" />
interface SerialPortInfo {
name?: string
serialNumber?: string
manufacturer?: string
product?: string
name?: string;
serialNumber?: string;
manufacturer?: string;
product?: string;
}

View File

@@ -1,65 +1,77 @@
import {invoke} from "@tauri-apps/api"
import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte"
import { invoke } from "@tauri-apps/api";
import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte";
export type TauriSerialPort = Pick<
SerialPort,
"getInfo" | "open" | "close" | "readable" | "writable" | "forget"
>
>;
function NativeSerialPort(info: SerialPortInfo): TauriSerialPort {
return {
getInfo() {
return info
return info;
},
async open({baudRate}: SerialOptions) {
await invoke("plugin:serial|open", {path: info.name, baudRate})
async open({ baudRate }: SerialOptions) {
await invoke("plugin:serial|open", { path: info.name, baudRate });
},
async close() {
await invoke("plugin:serial|close", {path: info.name})
await invoke("plugin:serial|close", { path: info.name });
},
async forget() {
// noop
},
readable: new ReadableStream({
async pull(controller) {
const result = await invoke<number[]>("plugin:serial|read", {path: info.name})
controller.enqueue(new Uint8Array(result))
const result = await invoke<number[]>("plugin:serial|read", {
path: info.name,
});
controller.enqueue(new Uint8Array(result));
},
}),
writable: new WritableStream({
async write(chunk) {
await invoke("plugin:serial|write", {path: info.name, chunk: Array.from(chunk)})
await invoke("plugin:serial|write", {
path: info.name,
chunk: Array.from(chunk),
});
},
}),
}
};
}
// @ts-expect-error polyfill
// noinspection JSConstantReassignment
navigator.serial = {
async getPorts(): Promise<SerialPort[]> {
return invoke<any[]>("plugin:serial|get_serial_ports").then(ports =>
return invoke<any[]>("plugin:serial|get_serial_ports").then((ports) =>
ports.map(NativeSerialPort),
) as Promise<SerialPort[]>
) as Promise<SerialPort[]>;
},
async requestPort(options?: SerialPortRequestOptions): Promise<SerialPort> {
const ports = await navigator.serial.getPorts().then(ports =>
const ports = await navigator.serial.getPorts().then((ports) =>
options?.filters !== undefined
? ports.filter(port =>
options.filters!.some(({usbVendorId, usbProductId}) => {
const info = port.getInfo()
? ports.filter((port) =>
options.filters!.some(({ usbVendorId, usbProductId }) => {
const info = port.getInfo();
return (
(usbVendorId === undefined || info.usbVendorId === usbVendorId) &&
(usbProductId === undefined || info.usbProductId === usbProductId)
)
(usbVendorId === undefined ||
info.usbVendorId === usbVendorId) &&
(usbProductId === undefined ||
info.usbProductId === usbProductId)
);
}),
)
: ports,
)
);
const dialog = new TauriSerialDialog({target: document.body, props: {ports}})
const port = await new Promise<SerialPort>(resolve => dialog.$on("confirm", resolve))
dialog.$destroy()
return port
const dialog = new TauriSerialDialog({
target: document.body,
props: { ports },
});
const port = await new Promise<SerialPort>((resolve) =>
// @ts-expect-error polyfill
dialog.$on("confirm", resolve),
);
dialog.$destroy();
return port;
},
}
};

View File

@@ -1,12 +0,0 @@
export async function updateDevice(port: SerialPort) {
await port.open({
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
bufferSize: 255,;
})
const writer = port.writable!.getWriter()
const reader = port.readable!.getReader()
}

View File

@@ -1,12 +1,12 @@
import {describe, it, expect} from "vitest"
import {compressActions, decompressActions} from "./actions"
import { describe, it, expect } from "vitest";
import { compressActions, decompressActions } from "./actions";
describe("layout", function () {
const actions = [1023, 255, 256, 42, 32, 532, 8000]
const actions = [1023, 255, 256, 42, 32, 532, 8000];
describe("compression", function () {
it("should compress back and forth arrays divisible by 4", function () {
expect(decompressActions(compressActions(actions))).toEqual(actions)
})
})
})
expect(decompressActions(compressActions(actions))).toEqual(actions);
});
});
});

View File

@@ -4,15 +4,15 @@
* Action codes <32 are invalid.
*/
export function compressActions(actions: number[]): Uint8Array {
const buffer = new Uint8Array(actions.length * 2)
let i = 0
const buffer = new Uint8Array(actions.length * 2);
let i = 0;
for (const action of actions) {
if (action > 0xff) {
buffer[i++] = action >>> 8
buffer[i++] = action >>> 8;
}
buffer[i++] = action & 0xff
buffer[i++] = action & 0xff;
}
return buffer.slice(0, i)
return buffer.slice(0, i);
}
/**
@@ -21,13 +21,13 @@ export function compressActions(actions: number[]): Uint8Array {
* @see {compressActions}
*/
export function decompressActions(raw: Uint8Array): number[] {
const actions: number[] = []
const actions: number[] = [];
for (let i = 0; i < raw.length; i++) {
let action = raw[i]
if (action > 0 && action < 32) {
action = (action << 8) | raw[++i]
let action = raw[i]!;
if (action > 0 && action < 32 && i + 1 < raw.length) {
action = (action << 8) | raw[++i]!;
}
actions.push(action)
actions.push(action);
}
return actions
return actions;
}

View File

@@ -1,12 +1,14 @@
import {describe, it, expect} from "vitest"
import {fromBase64, toBase64} from "./base64"
import { describe, it, expect } from "vitest";
import { fromBase64, toBase64 } from "./base64";
describe("base64", function () {
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21])
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21]);
it("should convert back-forth", async function () {
expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual(
data.buffer,
)
})
})
expect(
await fromBase64(await toBase64(new Blob([data]))).then((it) =>
it.arrayBuffer(),
),
).toEqual(data.buffer);
});
});

View File

@@ -5,8 +5,8 @@
* meaning some chars are swapped for compatibility
*/
export async function toBase64(blob: Blob): Promise<string> {
return new Promise(async resolve => {
const reader = new FileReader()
return new Promise(async (resolve) => {
const reader = new FileReader();
reader.onloadend = function () {
resolve(
`${(reader.result as string)
@@ -14,17 +14,20 @@ export async function toBase64(blob: Blob): Promise<string> {
.replaceAll("+", ".")
.replaceAll("/", "_")
.replaceAll("=", "-")}`,
)
}
reader.readAsDataURL(blob)
})
);
};
reader.readAsDataURL(blob);
});
}
export async function fromBase64(base64: string, fetch = window.fetch): Promise<Blob> {
export async function fromBase64(
base64: string,
fetch = window.fetch,
): Promise<Blob> {
return fetch(
`data:application/octet-stream;base64,${base64
.replaceAll(".", "+")
.replaceAll("_", "/")
.replaceAll("-", "=")}`,
).then(it => it.blob())
).then((it) => it.blob());
}

View File

@@ -1,21 +1,25 @@
[
[
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603, 114, 298, 32, 101, 604, 105,
127, 46, 111, 605, 39, 512, 44, 117, 552, 513, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566,
567, 609, 563, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296, 544, 116, 613,
108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518, 551, 542, 616, 336, 338, 335, 337, 617, 566,
568, 565, 567
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603,
114, 298, 32, 101, 604, 105, 127, 46, 111, 605, 39, 512, 44, 117, 552, 513,
514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566, 567, 609, 563,
63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296,
544, 116, 613, 108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518,
551, 542, 616, 336, 338, 335, 337, 617, 566, 568, 565, 567
],
[
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51, 50, 0, 52, 127, 54, 53, 0,
96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519,
297, 0, 98, 120, 91, 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516, 59, 115,
0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51,
50, 0, 52, 127, 54, 53, 0, 96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569,
572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519, 297, 0, 98, 120, 91,
93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516,
59, 115, 0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
],
[
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314, 298, 316, 315, 0, 317, 127,
319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0,
563, 63, 519, 297, 0, 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317, 299, 319,
318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338, 335, 337, 0, 570, 572, 569, 571
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314,
298, 316, 315, 0, 317, 127, 319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0,
540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0, 563, 63, 519, 297, 0,
98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317,
299, 319, 318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338,
335, 337, 0, 570, 572, 569, 571
]
]

View File

@@ -1,37 +1,49 @@
import {compressActions, decompressActions} from "./actions"
import {fromBase64, toBase64} from "$lib/serialization/base64"
import { compressActions, decompressActions } from "./actions";
import { fromBase64, toBase64 } from "$lib/serialization/base64";
export interface NewCharaLayout {
charaLayoutVersion: 1
device: "one" | "lite" | string
charaLayoutVersion: 1;
device: "one" | "lite" | string;
/**
* Layers A1-A3, with numeric action codes on each
*/
layers: [number[], number[], number[]]
layers: [number[], number[], number[]];
}
export type CharaLayout = [number[], number[], number[]]
export type CharaLayout = [number[], number[], number[]];
/**
* Serialize a layout into a micro package
*/
export async function serializeLayout(layout: CharaLayout): Promise<Blob> {
const items = compressActions(layout.flat())
const stream = new Blob([items]).stream().pipeThrough(new CompressionStream("deflate"))
return new Response(stream).blob()
const items = compressActions(layout.flat());
const stream = new Blob([items])
.stream()
.pipeThrough(new CompressionStream("deflate"));
return new Response(stream).blob();
}
export async function deserializeLayout(layout: Blob): Promise<CharaLayout> {
const stream = layout.stream().pipeThrough(new DecompressionStream("deflate"))
const raw = await new Response(stream).arrayBuffer()
const actions = decompressActions(new Uint8Array(raw))
return [actions.slice(0, 90), actions.slice(90, 180), actions.slice(180, 270)]
const stream = layout
.stream()
.pipeThrough(new DecompressionStream("deflate"));
const raw = await new Response(stream).arrayBuffer();
const actions = decompressActions(new Uint8Array(raw));
return [
actions.slice(0, 90),
actions.slice(90, 180),
actions.slice(180, 270),
];
}
export async function layoutAsUrlComponent(layout: CharaLayout): Promise<string> {
return serializeLayout(layout).then(toBase64)
export async function layoutAsUrlComponent(
layout: CharaLayout,
): Promise<string> {
return serializeLayout(layout).then(toBase64);
}
export async function layoutFromUrlComponent(base64: string): Promise<CharaLayout> {
return fromBase64(base64).then(deserializeLayout)
export async function layoutFromUrlComponent(
base64: string,
): Promise<CharaLayout> {
return fromBase64(base64).then(deserializeLayout);
}

View File

@@ -1,45 +1,45 @@
export interface VisualLayout {
name: string
col: VisualLayoutRow[]
name: string;
col: VisualLayoutRow[];
}
interface Positionable {
offset: [number, number]
rotate: number
offset: [number, number];
rotate: number;
}
export interface VisualLayoutRow extends Positionable {
row: Array<VisualLayoutKey | VisualLayoutSwitch>
row: Array<VisualLayoutKey | VisualLayoutSwitch>;
}
export interface VisualLayoutKey extends Positionable {
key: number
size?: [number, number]
key: number;
size?: [number, number];
}
export interface VisualLayoutSwitch extends Positionable {
switch: {
n: number
e: number
w: number
s: number
d: number
}
n: number;
e: number;
w: number;
s: number;
d: number;
};
}
export interface CompiledLayout {
name: string
size: [number, number]
keys: CompiledLayoutKey[]
name: string;
size: [number, number];
keys: CompiledLayoutKey[];
}
export interface CompiledLayoutKey {
id: number
shape: "quarter-circle" | "square"
cornerRadius: number
size: [number, number]
pos: [number, number]
rotate: number
id: number;
shape: "quarter-circle" | "square";
cornerRadius: number;
size: [number, number];
pos: [number, number];
rotate: number;
}
export function compileLayout(layout: VisualLayout): CompiledLayout {
@@ -47,18 +47,18 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
name: layout.name,
size: [0, 0],
keys: [],
}
};
let y = 0
for (const {row, offset} of layout.col) {
let x = offset?.[0] ?? 0
y += offset?.[1] ?? 0
let maxHeight = 0
let y = 0;
for (const { row, offset } of layout.col) {
let x = offset?.[0] ?? 0;
y += offset?.[1] ?? 0;
let maxHeight = 0;
for (const info of row) {
const [ox, oy] = info.offset || [0, 0]
const rotate = info.rotate || 0
const [ox, oy] = info.offset || [0, 0];
const rotate = info.rotate || 0;
if ("key" in info) {
const [width, height] = info.size ?? [1, 1]
const [width, height] = info.size ?? [1, 1];
compiled.keys.push({
id: info.key,
@@ -67,14 +67,19 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
pos: [x + ox, y + oy],
cornerRadius: 0.1,
rotate,
})
});
x += width + ox
maxHeight = Math.max(maxHeight, height + oy)
x += width + ox;
maxHeight = Math.max(maxHeight, height + oy);
} else if ("switch" in info) {
const cx = x + ox + 1
const cy = y + oy + 1
for (const [i, id] of [info.switch.s, info.switch.w, info.switch.n, info.switch.e].entries()) {
const cx = x + ox + 1;
const cy = y + oy + 1;
for (const [i, id] of [
info.switch.s,
info.switch.w,
info.switch.n,
info.switch.e,
].entries()) {
compiled.keys.push({
id,
shape: "quarter-circle",
@@ -82,7 +87,7 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
size: [2, 0.6],
pos: [cx, cy],
rotate: 90 * i + 45,
})
});
}
compiled.keys.push({
id: info.switch.d,
@@ -91,16 +96,16 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
size: [0.8, 0.8],
pos: [x + 0.6 + ox, y + 0.6 + oy],
rotate: 0,
})
});
x += 2 + ox
maxHeight = Math.max(maxHeight, 2 + oy)
x += 2 + ox;
maxHeight = Math.max(maxHeight, 2 + oy);
}
}
y += maxHeight
compiled.size[0] = Math.max(compiled.size[0], x)
y += maxHeight;
compiled.size[0] = Math.max(compiled.size[0], x);
}
compiled.size[1] = y
compiled.size[1] = y;
return compiled
return compiled;
}

View File

@@ -1,67 +1,83 @@
import type {Action} from "svelte/action"
import {changes, ChangeType, settings} from "$lib/undo-redo"
import type { Action } from "svelte/action";
import { changes, ChangeType, settings } from "$lib/undo-redo";
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
node: HTMLInputElement,
{id, inverse, scale},
export const setting: Action<
HTMLInputElement | HTMLSelectElement,
{ id: number; inverse?: number; scale?: number }
> = function (
node: HTMLInputElement | HTMLSelectElement,
{ id, inverse, scale },
) {
node.setAttribute("disabled", "")
const type = node.getAttribute("type") as "number" | "checkbox"
const min = node.hasAttribute("min") ? Number(node.getAttribute("min")) : undefined
const max = node.hasAttribute("max") ? Number(node.getAttribute("max")) : undefined
console.log(min, max, "|", id, "|", node.getAttribute("min"), node.getAttribute("max"))
node.setAttribute("disabled", "");
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
const isNumeric =
type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min")
? Number(node.getAttribute("min"))
: undefined;
const max = node.hasAttribute("max")
? Number(node.getAttribute("max"))
: undefined;
const unsubscribe = settings.subscribe(async settings => {
const unsubscribe = settings.subscribe(async (settings) => {
if (id in settings) {
const {value, isApplied} = settings[id]
if (type === "number") {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
inverse !== undefined ? inverse / value : scale !== undefined ? scale * value : value
).toString()
inverse !== undefined
? inverse / value
: scale !== undefined
? scale * value
: value
).toString();
} else {
node.checked = value !== 0
node.checked = value !== 0;
}
if (isApplied) {
node.classList.remove("pending-changes")
node.classList.remove("pending-changes");
} else {
node.classList.add("pending-changes")
node.classList.add("pending-changes");
}
node.removeAttribute("disabled")
node.removeAttribute("disabled");
} else {
node.setAttribute("disabled", "")
node.setAttribute("disabled", "");
}
})
});
async function listener() {
let value: number
if (type === "number") {
value = Number(node.value)
if (Number.isNaN(value)) return
let value: number;
if (isNumeric) {
value = Number(node.value);
if (Number.isNaN(value)) return;
value = Math.floor(
inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : value,
)
if (min !== undefined) value = Math.max(min, value)
if (max !== undefined) value = Math.min(max, value)
inverse !== undefined
? inverse / value
: scale !== undefined
? value / scale
: value,
);
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
} else {
value = node.checked ? 1 : 0
value = node.checked ? 1 : 0;
}
changes.update(changes => {
changes.update((changes) => {
changes.push({
type: ChangeType.Setting,
id: id,
setting: value,
})
return changes
})
});
return changes;
});
}
node.addEventListener("change", listener)
node.addEventListener("change", listener);
return {
destroy() {
node.removeEventListener("change", listener)
unsubscribe()
node.removeEventListener("change", listener);
unsubscribe();
},
}
}
};
};

View File

@@ -1,22 +1,25 @@
import type {Action} from "svelte/action"
import {readonly, writable} from "svelte/store"
import type { Action } from "svelte/action";
import { readonly, writable } from "svelte/store";
const setCanShare = writable(false)
export const canShare = readonly(setCanShare)
const setCanShare = writable(false);
export const canShare = readonly(setCanShare);
let shareCallback: ((event: Event) => void) | undefined
let shareCallback: ((event: Event) => void) | undefined;
export function triggerShare(event: Event) {
shareCallback?.(event)
shareCallback?.(event);
}
export const share: Action<Window, (event: Event) => void> = (node, callback: (event: Event) => void) => {
setCanShare.set(true)
shareCallback = callback
export const share: Action<Window, (event: Event) => void> = (
_node,
callback: (event: Event) => void,
) => {
setCanShare.set(true);
shareCallback = callback;
return {
destroy() {
setCanShare.set(false)
shareCallback = undefined
setCanShare.set(false);
shareCallback = undefined;
},
}
}
};
};

View File

@@ -1,14 +1,19 @@
import {describe, it, expect} from "vitest"
import {deserializeActionArray, serializeActionArray} from "./action-array"
import { describe, it, expect } from "vitest";
import { deserializeActionArray, serializeActionArray } from "./action-array";
describe("action array", () => {
it("should work with number arrays", () => {
expect(deserializeActionArray(serializeActionArray([62, 256, 1235]))).toEqual([62, 256, 1235])
})
expect(
deserializeActionArray(serializeActionArray([62, 256, 1235])),
).toEqual([62, 256, 1235]);
});
it("should work with nested arrays", () => {
expect(deserializeActionArray(serializeActionArray([[], [[]]]))).toEqual([[], [[]]])
})
expect(deserializeActionArray(serializeActionArray([[], [[]]]))).toEqual([
[],
[[]],
]);
});
it("should compress back and forth", () => {
expect(
@@ -23,31 +28,37 @@ describe("action array", () => {
[43, 746, 634],
[34, 63],
[332, 34],
])
})
]);
});
it("should compress a full layout", () => {
const layout = Object.freeze([
Object.freeze([
0, 0, 0, 0, 0, 53, 119, 45, 103, 122, 52, 107, 118, 109, 99, 51, 114, 36, 59, 101, 50, 105, 34, 46,
111, 49, 39, 515, 44, 117, 0, 512, 514, 513, 550, 0, 319, 318, 321, 320, 326, 315, 314, 317, 316, 0,
0, 0, 0, 0, 54, 98, 120, 536, 113, 55, 102, 112, 104, 100, 56, 97, 296, 544, 116, 57, 108, 299, 106,
110, 48, 121, 297, 61, 115, 0, 518, 516, 517, 553, 0, 336, 338, 335, 337, 0, 325, 322, 323, 324,
0, 0, 0, 0, 0, 53, 119, 45, 103, 122, 52, 107, 118, 109, 99, 51, 114,
36, 59, 101, 50, 105, 34, 46, 111, 49, 39, 515, 44, 117, 0, 512, 514,
513, 550, 0, 319, 318, 321, 320, 326, 315, 314, 317, 316, 0, 0, 0, 0, 0,
54, 98, 120, 536, 113, 55, 102, 112, 104, 100, 56, 97, 296, 544, 116,
57, 108, 299, 106, 110, 48, 121, 297, 61, 115, 0, 518, 516, 517, 553, 0,
336, 338, 335, 337, 0, 325, 322, 323, 324,
]),
Object.freeze([
0, 0, 0, 0, 0, 0, 91, 0, 0, 0, 0, 53, 0, 47, 52, 0, 51, 298, 0, 50, 0, 0, 127, 0, 49, 0, 0, 515, 0, 0,
0, 512, 514, 513, 550, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 93, 0, 536, 0, 0, 54, 0, 92,
55, 0, 56, 296, 544, 57, 0, 96, 299, 0, 48, 0, 0, 297, 0, 0, 0, 518, 516, 517, 553, 0, 336, 338, 335,
337, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 91, 0, 0, 0, 0, 53, 0, 47, 52, 0, 51, 298, 0, 50, 0,
0, 127, 0, 49, 0, 0, 515, 0, 0, 0, 512, 514, 513, 550, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 93, 0, 536, 0, 0, 54, 0, 92, 55, 0, 56,
296, 544, 57, 0, 96, 299, 0, 48, 0, 0, 297, 0, 0, 0, 518, 516, 517, 553,
0, 336, 338, 335, 337, 0, 0, 0, 0, 0,
]),
Object.freeze([
0, 0, 0, 0, 0, 0, 64, 95, 43, 0, 0, 126, 38, 63, 40, 0, 35, 298, 36, 123, 0, 33, 127, 37, 60, 0, 34,
515, 0, 0, 0, 512, 514, 513, 550, 0, 333, 331, 330, 334, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 536,
0, 0, 94, 58, 124, 41, 0, 42, 296, 544, 125, 0, 126, 299, 0, 62, 0, 0, 297, 0, 0, 0, 518, 516, 517,
553, 0, 336, 338, 335, 337, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 64, 95, 43, 0, 0, 126, 38, 63, 40, 0, 35, 298, 36,
123, 0, 33, 127, 37, 60, 0, 34, 515, 0, 0, 0, 512, 514, 513, 550, 0,
333, 331, 330, 334, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 536, 0, 0,
94, 58, 124, 41, 0, 42, 296, 544, 125, 0, 126, 299, 0, 62, 0, 0, 297, 0,
0, 0, 518, 516, 517, 553, 0, 336, 338, 335, 337, 0, 0, 0, 0, 0,
]),
])
]);
expect(deserializeActionArray(serializeActionArray(layout as number[][]))).toEqual(layout)
})
})
expect(
deserializeActionArray(serializeActionArray(layout as number[][])),
).toEqual(layout);
});
});

View File

@@ -1,55 +1,63 @@
import {compressActions, decompressActions} from "../serialization/actions"
import {CHARA_FILE_TYPES} from "../share/share-url"
import { compressActions, decompressActions } from "../serialization/actions";
import { CHARA_FILE_TYPES } from "../share/share-url";
export type ActionArray = number[] | ActionArray[]
export type ActionArray = number[] | ActionArray[];
export function serializeActionArray(array: ActionArray): Uint8Array {
let out = new Uint8Array(5)
const writer = new DataView(out.buffer)
writer.setUint32(0, array.length)
let out = new Uint8Array(5);
const writer = new DataView(out.buffer);
writer.setUint32(0, array.length);
if (array.length === 0) {
return out
return out;
} else if (typeof array[0] === "number") {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number"))
const compressed = compressActions(array as number[])
writer.setUint32(0, compressed.length)
return concatUint8Arrays(out, compressed)
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number"));
const compressed = compressActions(array as number[]);
writer.setUint32(0, compressed.length);
return concatUint8Arrays(out, compressed);
} else if (Array.isArray(array[0])) {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"))
return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray))
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"));
return concatUint8Arrays(
out,
...(array as ActionArray[]).map(serializeActionArray),
);
} else {
throw new Error("Not implemented")
throw new Error("Not implemented");
}
}
export function deserializeActionArray(raw: Uint8Array, cursor = {pos: 0}): ActionArray {
const reader = new DataView(raw.buffer)
const length = reader.getUint32(cursor.pos)
cursor.pos += 4
const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)]
cursor.pos++
export function deserializeActionArray(
raw: Uint8Array,
cursor = { pos: 0 },
): ActionArray {
const reader = new DataView(raw.buffer);
const length = reader.getUint32(cursor.pos);
cursor.pos += 4;
const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)];
cursor.pos++;
if (type === "number") {
const decompressed = decompressActions(raw.slice(cursor.pos, cursor.pos + length))
cursor.pos += length
return decompressed
const decompressed = decompressActions(
raw.slice(cursor.pos, cursor.pos + length),
);
cursor.pos += length;
return decompressed;
} else if (type === "array") {
const out = []
const out = [];
for (let i = 0; i < length; i++) {
out.push(deserializeActionArray(raw, cursor))
out.push(deserializeActionArray(raw, cursor));
}
return out
return out;
} else {
throw new Error("Not implemented")
throw new Error("Not implemented");
}
}
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const out = new Uint8Array(arrays.reduce((a, b) => a + b.length, 0))
let offset = 0
const out = new Uint8Array(arrays.reduce((a, b) => a + b.length, 0));
let offset = 0;
for (const array of arrays) {
out.set(array, offset)
offset += array.length
out.set(array, offset);
offset += array.length;
}
return out
return out;
}

View File

@@ -1,23 +1,23 @@
export interface CharaFile<T extends string> {
charaVersion: 1
type: T
charaVersion: 1;
type: T;
}
export interface CharaLayoutFile extends CharaFile<"layout"> {
device?: "ONE" | "LITE" | string
layout: [number[], number[], number[]]
device?: "ONE" | "LITE" | string;
layout: [number[], number[], number[]];
}
export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]][]
chords: [number[], number[]][];
}
export interface CharaSettingsFile extends CharaFile<"settings"> {
settings: number[]
settings: number[];
}
export interface CharaBackupFile extends CharaFile<"backup"> {
history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][]
history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][];
}
export type CharaFiles = CharaLayoutFile | CharaChordFile | CharaSettingsFile
export type CharaFiles = CharaLayoutFile | CharaChordFile | CharaSettingsFile;

View File

@@ -1,13 +1,19 @@
import type {CharaFile, CharaFiles} from "../share/chara-file"
import type {ActionArray} from "../share/action-array"
import {deserializeActionArray, serializeActionArray} from "../share/action-array"
import {fromBase64, toBase64} from "../serialization/base64"
import type { CharaFile, CharaFiles } from "../share/chara-file";
import type { ActionArray } from "../share/action-array";
import {
deserializeActionArray,
serializeActionArray,
} from "../share/action-array";
import { fromBase64, toBase64 } from "../serialization/base64";
type CharaLayoutOrder = {
[K in CharaFiles["type"]]: Array<
[Exclude<keyof Extract<CharaFiles, {type: K}>, keyof CharaFile<any>>, (typeof CHARA_FILE_TYPES)[number]]
>
}
[
Exclude<keyof Extract<CharaFiles, { type: K }>, keyof CharaFile<any>>,
(typeof CHARA_FILE_TYPES)[number],
]
>;
};
const keys: CharaLayoutOrder = {
layout: [
@@ -16,51 +22,60 @@ const keys: CharaLayoutOrder = {
],
chords: [["chords", "array"]],
settings: [["settings", "array"]],
}
};
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const
export const CHARA_FILE_TYPES = [
"unknown",
"number",
"string",
"array",
] as const;
const sep = "\n"
const sep = "\n";
export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`
export async function charaFileToUriComponent<T extends CharaFiles>(
file: T,
): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`;
for (const [key, type] of keys[file.type]) {
const value = file[key as keyof T]
url += sep
const value = file[key as keyof T];
url += sep;
if (type === "string") {
url += value as string
url += value as string;
} else if (type === "array") {
const stream = new Blob([serializeActionArray(value as ActionArray)])
.stream()
.pipeThrough(new CompressionStream("deflate"))
url += await toBase64(await new Response(stream).blob())
.pipeThrough(new CompressionStream("deflate"));
url += await toBase64(await new Response(stream).blob());
} else {
throw new Error("Not implemented")
throw new Error("Not implemented");
}
}
return url
return url;
}
export async function charaFileFromUriComponent<T extends CharaFiles>(
uriComponent: string,
fetch = window.fetch,
): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep)
const file: any = {type: fileType, charaVersion: Number(version)}
const [fileType, version, ...values] = uriComponent.split(sep);
const file: any = { type: fileType, charaVersion: Number(version) };
for (const [key, type] of keys[fileType as keyof typeof keys]) {
const value = values.shift()!
const value = values.shift()!;
if (type === "string") {
file[key] = value
file[key] = value;
} else if (type === "array") {
const stream = (await fromBase64(value, fetch)).stream().pipeThrough(new DecompressionStream("deflate"))
const actions = new Uint8Array(await new Response(stream).arrayBuffer())
console.log(actions)
file[key] = deserializeActionArray(actions)
const stream = (await fromBase64(value, fetch))
.stream()
.pipeThrough(new DecompressionStream("deflate"));
const actions = new Uint8Array(await new Response(stream).arrayBuffer());
console.log(actions);
file[key] = deserializeActionArray(actions);
}
}
return file
return file;
}

View File

@@ -1,17 +1,25 @@
import type {Writable} from "svelte/store"
import {writable} from "svelte/store"
import {browser} from "$app/environment"
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
import { browser } from "$app/environment";
export function persistentWritable<T>(key: string, value: T, condition?: () => boolean): Writable<T> {
export function persistentWritable<T>(
key: string,
value: T,
condition?: () => boolean,
): Writable<T> {
if (browser) {
const persistedValue = localStorage.getItem(key)
const store = persistedValue !== null ? writable(JSON.parse(persistedValue)) : writable(value)
store.subscribe(value => {
if (!condition || condition()) localStorage.setItem(key, JSON.stringify(value))
})
const persistedValue = localStorage.getItem(key);
const store =
persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
store.subscribe((value) => {
if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value));
});
return store
return store;
} else {
return writable(value)
return writable(value);
}
}

View File

@@ -19,10 +19,23 @@ button {
font-family: inherit;
font-weight: 600;
color: currentcolor;
background: transparent;
border: none;
@media not (forced-colors: active) {
color: currentcolor;
background: transparent;
border: none;
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
color: ButtonText;
}
border-radius: 32px;
transition: all 250ms ease;
@@ -37,11 +50,11 @@ button {
font-size: 24px;
border-radius: 50%;
}
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
@media (forced-colors: active) {
padding: 2px;
margin: 2px;
}
}
&.compact {
@@ -49,27 +62,48 @@ button {
}
}
label:has(input):hover,
.button:hover:not(:active),
a:hover:not(:active),
button:hover:not(:active) {
filter: brightness(70%);
transition: filter 250ms ease;
@media not (forced-colors: active) {
label:has(input):hover,
.button:hover:not(:active),
a:hover:not(:active),
button:hover:not(:active) {
filter: brightness(70%);
transition: filter 250ms ease;
&:has(:checked),
&.active {
filter: brightness(120%);
&:has(:checked),
&.active {
filter: brightness(120%);
}
&:disabled,
&.disabled {
opacity: 0.5;
filter: none;
}
}
}
&:disabled,
&.disabled {
opacity: 0.5;
filter: none;
@media (forced-colors: active) {
label:has(input) .button,
a button {
&:hover {
color: ActiveText;
}
&.active,
&:active {
color: SelectedItemText;
background: SelectedItem;
}
}
}
.disabled,
:disabled {
pointer-events: none;
opacity: 0.5;
@media not (forced-colors: active) {
opacity: 0.5;
}
@media (forced-colors: active) {
color: GrayText;
}
}

View File

@@ -1,23 +1,25 @@
::-webkit-scrollbar {
width: 8px;
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-color, white);
border-radius: 4px;
transition: all 250ms ease;
&:hover {
filter: brightness(120%);
@media not (forced-colors: active) {
::-webkit-scrollbar {
width: 8px;
background: transparent;
border-radius: 4px;
}
&:active {
filter: brightness(80%);
::-webkit-scrollbar-thumb {
background: var(--scrollbar-color, white);
border-radius: 4px;
transition: all 250ms ease;
&:hover {
filter: brightness(120%);
}
&:active {
filter: brightness(80%);
}
}
::-webkit-resizer {
display: none;
}
}
::-webkit-resizer {
display: none;
}

View File

@@ -1,10 +1,14 @@
import {argbFromHex, hexFromArgb, themeFromSourceColor} from "@material/material-color-utilities"
import {
argbFromHex,
hexFromArgb,
themeFromSourceColor,
} from "@material/material-color-utilities";
export const themeBase = "#6D81C7"
export const themeSuccessBase = "#00ff00"
export const themeBase = "#6D81C7";
export const themeSuccessBase = "#00ff00";
const theme = themeFromSourceColor(argbFromHex(themeBase), [
{name: "success", value: argbFromHex(themeSuccessBase), blend: true},
])
{ name: "success", value: argbFromHex(themeSuccessBase), blend: true },
]);
export const themeColor = hexFromArgb(theme.schemes.dark.background)
export const themeColor = hexFromArgb(theme.schemes.dark.background);

View File

@@ -22,6 +22,17 @@ $padding: 16px;
border-#{$placement}-color: var(--md-sys-color-surface-variant);
}
}
@media (forced-colors: active) {
color: CanvasText;
background-color: Canvas;
filter: none;
border: 1px solid CanvasText;
> .tippy-arrow {
display: none;
}
}
}
.tippy-box[data-theme~="tooltip"] {

View File

@@ -1,14 +1,15 @@
import type {Action} from "svelte/action"
import tippy from "tippy.js"
import type {SvelteComponent} from "svelte"
import Tooltip from "$lib/components/Tooltip.svelte"
import hotkeys from "hotkeys-js"
import type { Action } from "svelte/action";
import tippy from "tippy.js";
import type { SvelteComponent } from "svelte";
import Tooltip from "$lib/components/Tooltip.svelte";
export const action: Action<Element, {title?: string; shortcut?: string}> = (
export const hotkeys = new Map<string, HTMLElement>();
export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{title, shortcut},
{ title, shortcut },
) => {
let component: SvelteComponent | undefined
let component: SvelteComponent | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
@@ -16,30 +17,25 @@ export const action: Action<Element, {title?: string; shortcut?: string}> = (
onShow(instance) {
component ??= new Tooltip({
target: instance.popper.querySelector(".tippy-content") as Element,
props: {title, shortcut},
})
props: { title, shortcut },
});
},
onHidden() {
component?.$destroy()
component = undefined
component?.$destroy();
component = undefined;
},
})
});
if (shortcut && node instanceof HTMLElement) {
hotkeys(shortcut, function (keyboardEvent) {
keyboardEvent.preventDefault()
node.click()
})
hotkeys.set(shortcut, node);
}
return {
update(updated) {
title = updated.title
shortcut = updated.shortcut
},
destroy() {
tooltip.destroy()
hotkeys.unbind(shortcut)
tooltip.destroy();
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
},
}
}
};
};

View File

@@ -1,13 +1,16 @@
import type {Action} from "svelte/action"
import tippy from "tippy.js"
import type {Props} from "tippy.js"
import type { Action } from "svelte/action";
import tippy from "tippy.js";
import type { Props } from "tippy.js";
export const tooltip: Action<HTMLElement, Partial<Props>> = function (node, props) {
const instance = tippy(node, props)
export const tooltip: Action<HTMLElement, Partial<Props>> = function (
node,
props,
) {
const instance = tippy(node, props);
return {
destroy() {
instance.destroy()
instance.destroy();
},
}
}
};
};

View File

@@ -1,8 +1,12 @@
import {persistentWritable} from "$lib/storage"
import {derived} from "svelte/store"
import type {Chord} from "$lib/serial/chord"
import {deviceChords, deviceLayout, deviceSettings} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import { persistentWritable } from "$lib/storage";
import { derived } from "svelte/store";
import type { Chord } from "$lib/serial/chord";
import {
deviceChords,
deviceLayout,
deviceSettings,
} from "$lib/serial/connection";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
export enum ChangeType {
Layout,
@@ -11,126 +15,133 @@ export enum ChangeType {
}
export interface LayoutChange {
type: ChangeType.Layout
id: number
layer: number
action: number
type: ChangeType.Layout;
id: number;
layer: number;
action: number;
}
export interface ChordChange {
type: ChangeType.Chord
deleted?: true
id: number[]
actions: number[]
phrase: number[]
type: ChangeType.Chord;
deleted?: true;
id: number[];
actions: number[];
phrase: number[];
}
export interface SettingChange {
type: ChangeType.Setting
id: number
setting: number
type: ChangeType.Setting;
id: number;
setting: number;
}
export interface ChangeInfo {
isApplied: boolean
isCommitted?: boolean
isApplied: boolean;
isCommitted?: boolean;
}
export type Change = LayoutChange | ChordChange | SettingChange
export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[]>("changes", [])
export const changes = persistentWritable<Change[]>("changes", []);
export interface Overlay {
layout: [Map<number, number>, Map<number, number>, Map<number, number>]
chords: Map<string, Chord & {deleted: boolean}>
settings: Map<number, number>
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
chords: Map<string, Chord & { deleted: boolean }>;
settings: Map<number, number>;
}
export const overlay = derived(changes, changes => {
export const overlay = derived(changes, (changes) => {
const overlay: Overlay = {
layout: [new Map(), new Map(), new Map()],
chords: new Map(),
settings: new Map(),
}
};
for (const change of changes) {
switch (change.type) {
case ChangeType.Layout:
overlay.layout[change.layer].set(change.id, change.action)
break
overlay.layout[change.layer]?.set(change.id, change.action);
break;
case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), {
actions: change.actions,
phrase: change.phrase,
deleted: change.deleted ?? false,
})
break
});
break;
case ChangeType.Setting:
overlay.settings.set(change.id, change.setting)
break
overlay.settings.set(change.id, change.setting);
break;
}
}
return overlay
})
return overlay;
});
export const settings = derived([overlay, deviceSettings], ([overlay, settings]) =>
settings.map<{value: number} & ChangeInfo>((value, id) => ({
value: overlay.settings.get(id) ?? value,
isApplied: !overlay.settings.has(id),
})),
)
export const settings = derived(
[overlay, deviceSettings],
([overlay, settings]) =>
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
value: overlay.settings.get(id) ?? value,
isApplied: !overlay.settings.has(id),
})),
);
export type KeyInfo = {action: number} & ChangeInfo
export type KeyInfo = { action: number } & ChangeInfo;
export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
layout.map(
(actions, layer) =>
actions.map<KeyInfo>((action, id) => ({
action: overlay.layout[layer].get(id) ?? action,
isApplied: !overlay.layout[layer].has(id),
action: overlay.layout[layer]?.get(id) ?? action,
isApplied: !overlay.layout[layer]?.has(id),
})) as [KeyInfo, KeyInfo, KeyInfo],
),
)
);
export type ChordInfo = Chord &
ChangeInfo & {phraseChanged: boolean; actionsChanged: boolean; sortBy: string} & {
id: number[]
deleted: boolean
}
ChangeInfo & {
phraseChanged: boolean;
actionsChanged: boolean;
sortBy: string;
} & {
id: number[];
deleted: boolean;
};
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
const newChords = new Set(overlay.chords.keys())
const newChords = new Set(overlay.chords.keys());
const changedChords = chords.map<ChordInfo>(chord => {
const id = JSON.stringify(chord.actions)
const changedChords = chords.map<ChordInfo>((chord) => {
const id = JSON.stringify(chord.actions);
if (overlay.chords.has(id)) {
newChords.delete(id)
const changedChord = overlay.chords.get(id)!
newChords.delete(id);
const changedChord = overlay.chords.get(id)!;
return {
id: chord.actions,
// use the old phrase for stable editing
sortBy: chord.phrase.map(it => KEYMAP_CODES[it]?.id ?? it).join(),
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
actions: changedChord.actions,
phrase: changedChord.phrase,
actionsChanged: id !== JSON.stringify(changedChord.actions),
phraseChanged: JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
phraseChanged:
JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
isApplied: false,
deleted: changedChord.deleted,
}
};
} else {
return {
id: chord.actions,
sortBy: chord.phrase.map(it => KEYMAP_CODES[it]?.id ?? it).join(),
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
actions: chord.actions,
phrase: chord.phrase,
phraseChanged: false,
actionsChanged: false,
isApplied: true,
deleted: false,
}
};
}
})
});
for (const id of newChords) {
const chord = overlay.chords.get(id)!
const chord = overlay.chords.get(id)!;
changedChords.push({
sortBy: "",
isApplied: false,
@@ -140,8 +151,10 @@ export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
id: JSON.parse(id),
phrase: chord.phrase,
actions: chord.actions,
})
});
}
return changedChords.sort(({sortBy: a}, {sortBy: b}) => a.localeCompare(b))
})
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
a.localeCompare(b),
);
});

View File

@@ -1,5 +1,5 @@
<script>
import {page} from "$app/stores"
import { page } from "$app/stores";
</script>
<h1>{$page.status}</h1>

View File

@@ -1,8 +1,12 @@
import {themeBase, themeColor, themeSuccessBase} from "$lib/style/theme.server"
import type {LayoutServerLoad} from "./$types"
import {
themeBase,
themeColor,
themeSuccessBase,
} from "$lib/style/theme.server";
import type { LayoutServerLoad } from "./$types";
export const load = (async () => ({
themeSuccessBase,
themeBase,
themeColor,
})) satisfies LayoutServerLoad
})) satisfies LayoutServerLoad;

View File

@@ -1,38 +1,44 @@
<script lang="ts">
import "$lib/fonts/noto-sans-mono.scss"
import "$lib/fonts/material-symbols-rounded.scss"
import "$lib/style/scrollbar.scss"
import "$lib/style/tippy.scss"
import "$lib/style/theme.scss"
import {onDestroy, onMount} from "svelte"
import {applyTheme, argbFromHex, themeFromSourceColor} from "@material/material-color-utilities"
import Navigation from "./Navigation.svelte"
import {canAutoConnect} from "$lib/serial/device"
import {initSerial} from "$lib/serial/connection"
import type {LayoutData} from "./$types"
import {browser} from "$app/environment"
import BrowserWarning from "./BrowserWarning.svelte"
import "tippy.js/animations/shift-away.css"
import "tippy.js/dist/tippy.css"
import tippy from "tippy.js"
import {theme, userPreferences} from "$lib/preferences.js"
import {LL, setLocale} from "../i18n/i18n-svelte"
import {loadLocale} from "../i18n/i18n-util.sync"
import {detectLocale} from "../i18n/i18n-util"
import type {Locales} from "../i18n/i18n-types"
import Footer from "./Footer.svelte"
import {runLayoutDetection} from "$lib/os-layout.js"
import PageTransition from "./PageTransition.svelte"
import {restoreFromFile} from "$lib/backup/backup"
import {goto} from "$app/navigation"
import "$lib/fonts/noto-sans-mono.scss";
import "$lib/fonts/material-symbols-rounded.scss";
import "$lib/style/scrollbar.scss";
import "$lib/style/tippy.scss";
import "$lib/style/theme.scss";
import { onDestroy, onMount } from "svelte";
import {
applyTheme,
argbFromHex,
themeFromSourceColor,
} from "@material/material-color-utilities";
import Navigation from "./Navigation.svelte";
import { canAutoConnect } from "$lib/serial/device";
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
import BrowserWarning from "./BrowserWarning.svelte";
import "tippy.js/animations/shift-away.css";
import "tippy.js/dist/tippy.css";
import tippy from "tippy.js";
import { theme, userPreferences } from "$lib/preferences.js";
import { LL, setLocale } from "../i18n/i18n-svelte";
import { loadLocale } from "../i18n/i18n-util.sync";
import { detectLocale } from "../i18n/i18n-util";
import type { Locales } from "../i18n/i18n-types";
import Footer from "./Footer.svelte";
import { osLayout, runLayoutDetection } from "$lib/os-layout.js";
import PageTransition from "./PageTransition.svelte";
import { restoreFromFile } from "$lib/backup/backup";
import { goto } from "$app/navigation";
import { hotkeys } from "$lib/title";
const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale()
loadLocale(locale)
setLocale(locale)
let stopLayoutDetection: () => void
const locale =
((browser && localStorage.getItem("locale")) as Locales) || detectLocale();
loadLocale(locale);
setLocale(locale);
let stopLayoutDetection: () => void;
if (browser) {
stopLayoutDetection = runLayoutDetection()
stopLayoutDetection = runLayoutDetection();
tippy.setDefaultProps({
animation: "shift-away",
theme: "surface-variant",
@@ -40,38 +46,65 @@
duration: 250,
maxWidth: "none",
arrow: true,
})
});
}
export let data: LayoutData
export let data: LayoutData;
onMount(async () => {
theme.subscribe(it => {
const theme = themeFromSourceColor(argbFromHex(it.color))
const dark = it.mode === "dark" // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, {target: document.body, dark})
})
theme.subscribe((it) => {
const theme = themeFromSourceColor(argbFromHex(it.color));
const dark = it.mode === "dark"; // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, { target: document.body, dark });
});
if (import.meta.env.TAURI_FAMILY === undefined) {
const {initPwa} = await import("./pwa-setup")
webManifestLink = await initPwa()
const { initPwa } = await import("./pwa-setup");
webManifestLink = await initPwa();
}
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial()
await initSerial();
}
if (data.importFile) {
restoreFromFile(data.importFile)
const url = new URL(location.href)
url.searchParams.delete("import")
await goto(url.href, {replaceState: true})
restoreFromFile(data.importFile);
const url = new URL(location.href);
url.searchParams.delete("import");
await goto(url.href, { replaceState: true });
}
})
});
onDestroy(() => {
stopLayoutDetection?.()
})
stopLayoutDetection?.();
});
let webManifestLink = ""
let webManifestLink = "";
function handleHotkey(event: KeyboardEvent) {
let key = $osLayout.get(event.code);
if (!key && event.code === "Escape") key = "esc";
if (!key && event.code === "ArrowLeft") key = "left";
if (!key && event.code === "ArrowRight") key = "right";
if (!key && event.code === "ArrowUp") key = "up";
if (!key && event.code === "ArrowDown") key = "down";
if (!key) return;
const str = [
event.ctrlKey ? "ctrl" : undefined,
event.shiftKey ? "shift" : undefined,
event.altKey ? "alt" : undefined,
key,
]
.filter((it) => !!it)
.join("+");
const node = hotkeys.get(str);
if (node) {
event.preventDefault();
node.click();
}
}
</script>
<svelte:head>
@@ -81,6 +114,8 @@
<meta name="theme-color" content={data.themeColor} />
</svelte:head>
<svelte:window on:keydown={handleHotkey} />
<Navigation />
<!-- <PickChangesDialog /> -->

View File

@@ -1,14 +1,16 @@
import type {LayoutLoad} from "./$types"
import {browser} from "$app/environment"
import {charaFileFromUriComponent} from "$lib/share/share-url"
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
export const prerender = true
export const trailingSlash = "always"
export const prerender = true;
export const trailingSlash = "always";
export const load = (async ({url, data, fetch}) => {
const importFile = browser && new URLSearchParams(url.search).get("import")
export const load = (async ({ url, data, fetch }) => {
const importFile = browser && new URLSearchParams(url.search).get("import");
return {
...data,
importFile: importFile ? await charaFileFromUriComponent(importFile, fetch) : undefined,
}
}) satisfies LayoutLoad
importFile: importFile
? await charaFileFromUriComponent(importFile, fetch)
: undefined,
};
}) satisfies LayoutLoad;

View File

@@ -1,6 +1,6 @@
import {redirect} from "@sveltejs/kit"
import type {PageLoad} from "./$types"
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load = (() => {
throw redirect(302, "/config/")
}) satisfies PageLoad
throw redirect(302, "/config/");
}) satisfies PageLoad;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import {preference} from "$lib/preferences"
import LL from "../i18n/i18n-svelte"
import { preference } from "$lib/preferences";
import LL from "../i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
@@ -8,11 +8,18 @@
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup"
} from "$lib/backup/backup";
</script>
<section>
<h2><label><input type="checkbox" use:preference={"backup"} />{$LL.backup.TITLE()}</label></h2>
<h2>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.TITLE()}</label
>
</h2>
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
@@ -36,7 +43,8 @@
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input on:input={restoreBackup} type="file" /><span class="icon">settings_backup_restore</span
><input on:input={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>

View File

@@ -1,5 +1,5 @@
<script>
import LL from "../i18n/i18n-svelte"
import LL from "../i18n/i18n-svelte";
</script>
<dialog open>
@@ -12,8 +12,9 @@
>{$LL.browserWarning.INFO_SERIAL_INFIX()}</a
>{$LL.browserWarning.INFO_SERIAL_SUFFIX()}
{$LL.browserWarning.INFO_BROWSER_PREFIX()}
<a href="https://github.com/brave/brave-browser/issues/13902" target="_blank"
>{$LL.browserWarning.INFO_BROWSER_INFIX()}</a
<a
href="https://github.com/brave/brave-browser/issues/13902"
target="_blank">{$LL.browserWarning.INFO_BROWSER_INFIX()}</a
>{$LL.browserWarning.INFO_BROWSER_SUFFIX()}
</p>
<div>

View File

@@ -1,18 +1,29 @@
<script>
import {page} from "$app/stores"
import {action} from "$lib/title"
import LL from "../i18n/i18n-svelte"
import { page } from "$app/stores";
import LL from "../i18n/i18n-svelte";
$: paths = [
{href: "/config/chords/", title: $LL.configure.chords.TITLE(), icon: "piano"},
{href: "/config/layout/", title: $LL.configure.layout.TITLE(), icon: "keyboard"},
{href: "/config/settings/", title: $LL.configure.settings.TITLE(), icon: "settings"},
]
{
href: "/config/chords/",
title: $LL.configure.chords.TITLE(),
icon: "piano",
},
{
href: "/config/layout/",
title: $LL.configure.layout.TITLE(),
icon: "keyboard",
},
{
href: "/config/settings/",
title: $LL.configure.settings.TITLE(),
icon: "settings",
},
];
</script>
<nav>
{#each paths as { href, title, icon }, i}
<a {href} class:active={$page.url.pathname.startsWith(href)} use:action={{shortcut: `shift+${i + 1}`}}>
{#each paths as { href, title, icon }}
<a {href} class:active={$page.url.pathname.startsWith(href)}>
<span class="icon">{icon}</span>
{title}
</a>

View File

@@ -1,46 +1,57 @@
<script lang="ts">
import {initSerial, serialPort} from "$lib/serial/connection"
import {browser} from "$app/environment"
import {slide, fade} from "svelte/transition"
import {preference} from "$lib/preferences"
import LL from "../i18n/i18n-svelte"
import { initSerial, serialPort } from "$lib/serial/connection";
import { browser } from "$app/environment";
import { slide, fade } from "svelte/transition";
import { preference } from "$lib/preferences";
import LL from "../i18n/i18n-svelte";
import { downloadBackup } from "$lib/backup/backup";
function reboot() {
$serialPort?.reboot()
$serialPort = undefined
powerDialog = false
$serialPort?.reboot();
$serialPort = undefined;
powerDialog = false;
setTimeout(() => {
initSerial()
}, 1000)
initSerial();
}, 1000);
}
function bootloader() {
$serialPort?.bootloader()
$serialPort = undefined
rebootInfo = true
powerDialog = false
downloadBackup();
$serialPort?.bootloader();
$serialPort = undefined;
rebootInfo = true;
powerDialog = false;
}
async function updateFirmware() {
const {usbVendorId: vendorId, usbProductId: productId} = $serialPort!.portInfo
$serialPort!.bootloader()
await new Promise(resolve => setTimeout(resolve, 1000))
console.log(await navigator.usb.requestDevice({filters: [{vendorId, productId}]}))
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
}
}
let rebootInfo = false
let terminal = false
let powerDialog = false
let rebootInfo = false;
let terminal = false;
let powerDialog = false;
$: if ($serialPort) {
rebootInfo = false
rebootInfo = false;
}
</script>
<section>
<div class="row">
<h2>{$LL.deviceManager.TITLE()}</h2>
<label>{$LL.deviceManager.AUTO_CONNECT()}<input type="checkbox" use:preference={"autoConnect"} /></label>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
</div>
{#if $serialPort}
@@ -51,22 +62,32 @@
<br />
Version {$serialPort.version}
</p>
{#if $serialPort.version.toString() !== import.meta.env.VITE_LATEST_FIRMWARE}
<a
href="https://docs.charachorder.com/CharaChorder%20One.html#updating-the-firmware"
>Firmware Update Instructions</a
>
{/if}
<!--<button on:click={updateFirmware}>Update</button>-->
{/if}
{#if browser}
{#if navigator.userAgent.includes("Linux") && !$serialPort}
<details class="linux-info" transition:slide>
<summary>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</summary>
In most cases you can simply follow the
<a target="_blank" href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
>
on serial port permissions.
<div class="linux-info">
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
<p>
In most cases you can simply follow the <a
target="_blank"
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
> on serial port permissions.
</p>
<p>Special systems:</p>
<ul>
<li>
<a target="_blank" href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
<a
target="_blank"
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
>Arch and Arch-based like Manjaro or EndeavourOS</a
>
</li>
@@ -78,27 +99,33 @@
>
</li>
<li>
<a target="_blank" href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
<a
target="_blank"
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
>Gentoo</a
>
</li>
</ul>
</details>
</div>
{/if}
{#if rebootInfo}
<p transition:slide><b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b></p>
<p transition:slide>
<b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b>
</p>
{/if}
<div class="row">
{#if $serialPort}
<button
class="secondary"
on:click={() => {
$serialPort?.forget()
$serialPort = undefined
}}><span class="icon">usb_off</span>{$LL.deviceManager.DISCONNECT()}</button
$serialPort?.forget();
$serialPort = undefined;
}}
><span class="icon">usb_off</span
>{$LL.deviceManager.DISCONNECT()}</button
>
{:else}
<button class="error" on:click={() => initSerial(true)}
<button class="error" on:click={connect}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{/if}
@@ -123,19 +150,21 @@
class="backdrop"
role="button"
tabindex="-1"
transition:fade={{duration: 250}}
transition:fade={{ duration: 250 }}
on:click={() => (powerDialog = !powerDialog)}
on:keypress={event => {
if (event.key === "Enter") powerDialog = !powerDialog
on:keypress={(event) => {
if (event.key === "Enter") powerDialog = !powerDialog;
}}
/>
<dialog open transition:slide={{duration: 250}}>
<dialog open transition:slide={{ duration: 250 }}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button on:click={reboot}
><span class="icon">restart_alt</span>{$LL.deviceManager.bootMenu.REBOOT()}</button
><span class="icon">restart_alt</span
>{$LL.deviceManager.bootMenu.REBOOT()}</button
>
<button on:click={bootloader}
><span class="icon">rule_settings</span>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
><span class="icon">rule_settings</span
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
>
</dialog>
{/if}
@@ -151,7 +180,7 @@
margin-block: 8px;
}
details a {
.linux-info a {
display: inline;
padding-inline: 0;
text-decoration: underline;
@@ -166,15 +195,6 @@
width: 300px;
}
summary {
cursor: pointer;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
}
.backdrop {
position: absolute;
z-index: 1;

View File

@@ -1,9 +1,16 @@
<script lang="ts">
import LL from "../i18n/i18n-svelte"
import {changes, ChangeType, chords, layout, overlay, settings} from "$lib/undo-redo"
import type {Change} from "$lib/undo-redo"
import {fly} from "svelte/transition"
import {action} from "$lib/title"
import LL from "../i18n/i18n-svelte";
import {
changes,
ChangeType,
chords,
layout,
overlay,
settings,
} from "$lib/undo-redo";
import type { Change } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { action } from "$lib/title";
import {
deviceChords,
deviceLayout,
@@ -11,72 +18,77 @@
serialPort,
syncProgress,
syncStatus,
} from "$lib/serial/connection"
import {askForConfirmation} from "$lib/dialogs/confirm-dialog"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
} from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
function undo(event: MouseEvent) {
if (event.shiftKey) {
changes.set([])
changes.set([]);
} else {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
redoQueue = [$changes.pop()!, ...redoQueue];
changes.update((it) => it);
}
}
function redo() {
const [change, ...queue] = redoQueue
changes.update(it => {
it.push(change)
return it
})
redoQueue = queue
const change = redoQueue.shift();
if (change) {
changes.update((it) => {
it.push(change);
return it;
});
}
}
let redoQueue: Change[] = []
let redoQueue: Change[] = [];
async function save() {
try {
const port = $serialPort
if (!port) return
$syncStatus = "uploading"
const port = $serialPort;
if (!port) return;
$syncStatus = "uploading";
for (const [id, {actions, phrase, deleted}] of $overlay.chords) {
for (const [id, { actions, phrase, deleted }] of $overlay.chords) {
if (!deleted) {
if (id !== JSON.stringify(actions)) {
const existingChord = await port.getChordPhrase(actions)
const existingChord = await port.getChordPhrase(actions);
if (
existingChord !== undefined &&
!(await askForConfirmation(
$LL.configure.chords.conflict.TITLE(),
$LL.configure.chords.conflict.DESCRIPTION(
actions.map(it => `<kbd>${KEYMAP_CODES[it].id}</kbd>`).join(" "),
),
$LL.configure.chords.conflict.DESCRIPTION(),
$LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(),
actions.slice(0, actions.lastIndexOf(0)),
))
) {
changes.update(changes =>
changes.filter(it => !(it.type === ChangeType.Chord && JSON.stringify(it.id) === id)),
)
continue
changes.update((changes) =>
changes.filter(
(it) =>
!(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
),
),
);
continue;
}
await port.deleteChord({actions: JSON.parse(id)})
await port.deleteChord({ actions: JSON.parse(id) });
}
await port.setChord({actions, phrase})
await port.setChord({ actions, phrase });
} else {
await port.deleteChord({actions})
await port.deleteChord({ actions });
}
}
for (const [layer, actions] of $overlay.layout.entries()) {
for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action)
await port.setLayoutKey(layer + 1, id, action);
}
}
for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting)
await port.setSetting(id, setting);
}
// Yes, this is a completely arbitrary and unnecessary delay.
@@ -86,60 +98,61 @@
// 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
// "Your device will break after you click this 10,000 times!"
const virtualWriteTime = 1000
const startStamp = performance.now()
await new Promise<void>(resolve => {
const virtualWriteTime = 1000;
const startStamp = performance.now();
await new Promise<void>((resolve) => {
function animate() {
const delta = performance.now() - startStamp
const delta = performance.now() - startStamp;
syncProgress.set({
max: virtualWriteTime,
current: delta,
})
});
if (delta >= virtualWriteTime) {
resolve()
resolve();
} else {
requestAnimationFrame(animate)
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate)
})
await port.commit()
requestAnimationFrame(animate);
});
await port.commit();
$deviceLayout = $layout.map(layer => layer.map<number>(({action}) => action)) as [
number[],
number[],
number[],
]
$deviceChords = $chords.filter(({deleted}) => !deleted).map(({actions, phrase}) => ({actions, phrase}))
$deviceSettings = $settings.map(({value}) => value)
$changes = []
$deviceLayout = $layout.map((layer) =>
layer.map<number>(({ action }) => action),
) as [number[], number[], number[]];
$deviceChords = $chords
.filter(({ deleted }) => !deleted)
.map(({ actions, phrase }) => ({ actions, phrase }));
$deviceSettings = $settings.map(({ value }) => value);
$changes = [];
} catch (e) {
alert(e)
console.error(e)
alert(e);
console.error(e);
} finally {
$syncStatus = "done"
$syncStatus = "done";
}
}
</script>
<button
use:action={{title: $LL.saveActions.UNDO(), shortcut: "ctrl+z"}}
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
class="icon"
disabled={$changes.length === 0}
on:click={undo}>undo</button
>
<button
use:action={{title: $LL.saveActions.REDO(), shortcut: "ctrl+y"}}
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
class="icon"
disabled={redoQueue.length === 0}
on:click={redo}>redo</button
>
{#if $changes.length !== 0}
<button
transition:fly={{x: 10}}
use:action={{title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s"}}
transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
on:click={save}
class="click-me"><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
>
{/if}

View File

@@ -1,44 +1,47 @@
<script lang="ts">
import {browser, version} from "$app/environment"
import {action} from "$lib/title"
import LL, {setLocale} from "../i18n/i18n-svelte"
import {theme} from "$lib/preferences.js"
import type {Locales} from "../i18n/i18n-types"
import {detectLocale, locales} from "../i18n/i18n-util"
import {loadLocaleAsync} from "../i18n/i18n-util.async"
import {tick} from "svelte"
import SyncOverlay from "./SyncOverlay.svelte"
import {serialPort} from "$lib/serial/connection"
import { browser, version } from "$app/environment";
import { action } from "$lib/title";
import LL, { setLocale } from "../i18n/i18n-svelte";
import { theme } from "$lib/preferences.js";
import type { Locales } from "../i18n/i18n-types";
import { detectLocale, locales } from "../i18n/i18n-util";
import { loadLocaleAsync } from "../i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import { serialPort } from "$lib/serial/connection";
let locale = (browser && (localStorage.getItem("locale") as Locales)) || detectLocale()
let locale =
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale();
$: if (browser)
(async () => {
localStorage.setItem("locale", locale)
await loadLocaleAsync(locale)
setLocale(locale)
})()
localStorage.setItem("locale", locale);
await loadLocaleAsync(locale);
setLocale(locale);
})();
function switchTheme() {
const mode = $theme.mode === "light" ? "dark" : "light"
const mode = $theme.mode === "light" ? "dark" : "light";
if (document.startViewTransition) {
document.startViewTransition(async () => {
$theme.mode = mode
await tick()
})
$theme.mode = mode;
await tick();
});
} else {
$theme.mode = mode
$theme.mode = mode;
}
}
let languageSelect: HTMLSelectElement
let languageSelect: HTMLSelectElement;
</script>
<footer>
<ul>
<li>
<!-- svelte-ignore not-defined -->
<a href={import.meta.env.VITE_HOMEPAGE_URL} rel="noreferrer" target="_blank"
><span class="icon">commit</span> v{version}</a
<a
href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer"
target="_blank"><span class="icon">commit</span> v{version}</a
>
</li>
<li>
@@ -51,11 +54,6 @@
><span class="icon">description</span> Docs</a
>
</li>
<li>
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
>
</li>
</ul>
<div>
{#if !$serialPort}
@@ -67,15 +65,37 @@
</div>
<ul>
<li>
<input use:action={{title: $LL.profile.theme.COLOR_SCHEME()}} type="color" bind:value={$theme.color} />
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
>
</li>
<li>
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
>
</li>
<li class="hide-forced-colors">
<input
use:action={{ title: $LL.profile.theme.COLOR_SCHEME() }}
type="color"
bind:value={$theme.color}
/>
</li>
<li class="hide-forced-colors">
{#if $theme.mode === "light"}
<button use:action={{title: $LL.profile.theme.DARK_MODE()}} class="icon" on:click={switchTheme}>
<button
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
class="icon"
on:click={switchTheme}
>
dark_mode
</button>
{:else if $theme.mode === "dark"}
<button use:action={{title: $LL.profile.theme.LIGHT_MODE()}} class="icon" on:click={switchTheme}>
<button
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
class="icon"
on:click={switchTheme}
>
light_mode
</button>
{/if}
@@ -83,7 +103,7 @@
<li>
<button
class="icon"
use:action={{title: $LL.profile.LANGUAGE()}}
use:action={{ title: $LL.profile.LANGUAGE() }}
on:click={() => languageSelect.click()}
>translate
@@ -151,6 +171,14 @@
padding-block-start: 0;
opacity: 0.4;
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: unset;
}
}
ul {
@@ -191,4 +219,10 @@
.icon {
font-size: 16px;
}
@media (forced-colors: active) {
.hide-forced-colors {
display: none;
}
}
</style>

View File

@@ -1,25 +1,25 @@
<script lang="ts">
import {serialPort, syncStatus} from "$lib/serial/connection"
import {slide, fly} from "svelte/transition"
import {canShare, triggerShare} from "$lib/share"
import {popup} from "$lib/popup"
import BackupPopup from "./BackupPopup.svelte"
import ConnectionPopup from "./ConnectionPopup.svelte"
import {browser} from "$app/environment"
import {userPreferences} from "$lib/preferences"
import {action} from "$lib/title"
import LL from "../i18n/i18n-svelte"
import ConfigTabs from "./ConfigTabs.svelte"
import EditActions from "./EditActions.svelte"
import {onMount} from "svelte"
import { serialPort, syncStatus } from "$lib/serial/connection";
import { slide, fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { popup } from "$lib/popup";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { browser } from "$app/environment";
import { userPreferences } from "$lib/preferences";
import { action } from "$lib/title";
import LL from "../i18n/i18n-svelte";
import ConfigTabs from "./ConfigTabs.svelte";
import EditActions from "./EditActions.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click()
connectButton.click();
}
})
});
let connectButton: HTMLButtonElement
let connectButton: HTMLButtonElement;
</script>
<nav>
@@ -32,14 +32,14 @@
<div class="actions">
{#if $canShare}
<button
use:action={{title: $LL.share.TITLE()}}
transition:fly={{x: -8}}
use:action={{ title: $LL.share.TITLE() }}
transition:fly={{ x: -8 }}
class="icon"
on:click={triggerShare}>share</button
>
<button
use:action={{title: $LL.print.TITLE()}}
transition:fly={{x: -8}}
use:action={{ title: $LL.print.TITLE() }}
transition:fly={{ x: -8 }}
class="icon"
on:click={() => print()}>print</button
>
@@ -50,7 +50,11 @@
<PwaStatus />
{/await}
{/if}
<button use:action={{title: $LL.backup.TITLE()}} use:popup={BackupPopup} class="icon {$syncStatus}">
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
{#if $userPreferences.backup}
history
{:else}
@@ -59,7 +63,7 @@
</button>
<button
bind:this={connectButton}
use:action={{title: $LL.deviceManager.TITLE()}}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}

View File

@@ -1,49 +1,53 @@
<script lang="ts">
import {fly} from "svelte/transition"
import {afterNavigate, beforeNavigate} from "$app/navigation"
import {expoIn, expoOut} from "svelte/easing"
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
let inDirection = 0
let outDirection = 0
let outroEnd: undefined | (() => void) = undefined
let animationDone: Promise<void>
let inDirection = 0;
let outDirection = 0;
let outroEnd: undefined | (() => void) = undefined;
let animationDone: Promise<void>;
let isNavigating = false
let isNavigating = false;
const routeOrder = ["/config/chords/", "/config/layout/", "/config/settings/"]
const routeOrder = [
"/config/chords/",
"/config/layout/",
"/config/settings/",
];
beforeNavigate(navigation => {
const from = navigation.from?.url.pathname
const to = navigation.to?.url.pathname
if (from === to) return
isNavigating = true
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0
outDirection = 0
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from)
const toIndex = routeOrder.indexOf(to)
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1
outDirection = fromIndex > toIndex ? 1 : -1
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise(resolve => {
outroEnd = resolve
})
})
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone
isNavigating = false
})
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{x: inDirection * 24, duration: 150, easing: expoOut}}
out:fly={{x: outDirection * 24, duration: 150, easing: expoIn}}
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
on:outroend={outroEnd}
>
<slot />

View File

@@ -1,21 +1,42 @@
<script lang="ts">
import {syncProgress, syncStatus} from "$lib/serial/connection"
import LL from "../i18n/i18n-svelte"
import {fly} from "svelte/transition"
import {
serialPort,
syncProgress,
syncStatus,
sync,
} from "$lib/serial/connection";
import LL from "../i18n/i18n-svelte";
import { slide } from "svelte/transition";
</script>
{#if $syncStatus !== "done"}
<div transition:fly={{y: 40}}>
<progress max={$syncProgress?.max ?? 1} value={$syncProgress?.current ?? 1}></progress>
{#if $syncStatus === "downloading"}
<div>{$LL.sync.TITLE_READ()}</div>
{:else}
<div>{$LL.sync.TITLE_WRITE()}</div>
{/if}
</div>
{/if}
<div class="container">
{#if $syncStatus !== "done"}
<div transition:slide>
<progress
max={$syncProgress?.max ?? 1}
value={$syncProgress?.current ?? 1}
></progress>
{#if $syncStatus === "downloading"}
<div>{$LL.sync.TITLE_READ()}</div>
{:else}
<div>{$LL.sync.TITLE_WRITE()}</div>
{/if}
</div>
{:else if $serialPort}
<button transition:slide on:click={sync}
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
>
{/if}
</div>
<style lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
div {
font-size: 12px;
}

View File

@@ -1,6 +1,6 @@
import {redirect} from "@sveltejs/kit"
import type {PageLoad} from "./$types"
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load = (() => {
throw redirect(302, "/config/layout/")
}) satisfies PageLoad
throw redirect(302, "/config/layout/");
}) satisfies PageLoad;

View File

@@ -1,5 +1,5 @@
<script>
import LL from "../../i18n/i18n-svelte"
import LL from "../../i18n/i18n-svelte";
</script>
{$LL.share.URL_COPIED()}

View File

@@ -1,89 +1,217 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
import {onDestroy, onMount, setContext} from "svelte"
import {changes, ChangeType, chords} from "$lib/undo-redo"
import type {ChordInfo} from "$lib/undo-redo"
import {derived, writable} from "svelte/store"
import ChordEdit from "./ChordEdit.svelte"
import {crossfade} from "svelte/transition"
import ChordActionEdit from "./ChordActionEdit.svelte"
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title";
import { onDestroy, onMount, setContext, tick } from "svelte";
import { changes, ChangeType, chords } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
import { derived, writable } from "svelte/store";
import ChordEdit from "./ChordEdit.svelte";
import { crossfade, fly } from "svelte/transition";
import ChordActionEdit from "./ChordActionEdit.svelte";
import { browser } from "$app/environment";
import { expoOut } from "svelte/easing";
import { osLayout } from "$lib/os-layout";
import randomTips from "$lib/assets/random-tips/en.json";
const resultSize = 38
let results: HTMLElement
const pageSize = writable(0)
let resizeObserver: ResizeObserver
const resultSize = 38;
let results: HTMLElement;
const pageSize = writable(0);
let resizeObserver: ResizeObserver;
let abortIndexing: (() => void) | undefined;
let progress = 0;
onMount(() => {
resizeObserver = new ResizeObserver(() => {
pageSize.set(Math.floor(results.clientHeight / resultSize))
})
pageSize.set(Math.floor(results.clientHeight / resultSize))
resizeObserver.observe(results)
})
pageSize.set(Math.floor(results.clientHeight / resultSize));
});
pageSize.set(Math.floor(results.clientHeight / resultSize));
resizeObserver.observe(results);
});
onDestroy(() => {
resizeObserver?.disconnect()
})
resizeObserver?.disconnect();
});
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
function buildIndex(chords: ChordInfo[]): Index {
const index = new Index({tokenize: "full"})
chords.forEach((chord, i) => {
if ("phrase" in chord) {
index.add(
i,
chord.phrase
.map(it => KEYMAP_CODES[it]?.id)
.filter(it => !!it)
.join(""),
)
}
})
return index
let index = new FlexSearch.Index();
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
$: {
abortIndexing?.();
progress = 0;
buildIndex($chords, $osLayout).then(searchIndex.set);
}
const searchFilter = writable<number[] | undefined>(undefined)
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
const plainPhrase: string[] = [""];
const extraActions: string[] = [];
const extraCodes: string[] = [];
function search(event: Event) {
const query = (event.target as HTMLInputElement).value
searchFilter.set(query && searchIndex ? searchIndex.search(query) : undefined)
page = 0
for (const actionCode of chord.phrase ?? []) {
const action = KEYMAP_CODES.get(actionCode);
if (!action) {
extraCodes.push(`0x${actionCode.toString(16)}`);
continue;
}
const osCode = action.keyCode && osLayout.get(action.keyCode);
const token = osCode?.length === 1 ? osCode : action.display || action.id;
if (!token) {
extraCodes.push(`0x${action.code.toString(16)}`);
continue;
}
if (/^\s$/.test(token) && plainPhrase.at(-1) !== "") {
plainPhrase.push("");
} else if (token.length === 1) {
plainPhrase[plainPhrase.length - 1] =
plainPhrase[plainPhrase.length - 1] + token;
} else {
extraActions.push(token);
}
}
if (chord.phrase?.[0] === 298) {
plainPhrase.push("suffix");
}
if (
["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) =>
extraActions.includes(it),
)
) {
plainPhrase.push("cursor warp");
}
if (
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) =>
extraActions.includes(it),
)
) {
plainPhrase.push("macro");
}
if (chord.actions[0] !== 0) {
plainPhrase.push("compound");
}
const input = chord.actions
.slice(chord.actions.lastIndexOf(0) + 1)
.map((it) => {
const info = KEYMAP_CODES.get(it);
if (!info) return `0x${it.toString(16)}`;
const osCode = info.keyCode && osLayout.get(info.keyCode);
const result = osCode?.length === 1 ? osCode : info.id;
return result ?? `0x${it.toString(16)}`;
});
return [
...plainPhrase,
`+${input.join("+")}`,
...new Set(extraActions),
...new Set(extraCodes),
].join(" ");
}
async function buildIndex(
chords: ChordInfo[],
osLayout: Map<string, string>,
): Promise<FlexSearch.Index> {
if (chords.length === 0 || !browser) return index;
index = new FlexSearch.Index({
tokenize: "full",
encode(phrase: string) {
return phrase.split(/\s+/).flatMap((it) => {
if (/^[A-Z_]+$/.test(it)) {
return it;
}
if (it.startsWith("+")) {
return it
.slice(1)
.split("+")
.map((it) => `+${it}`);
}
return it.toLowerCase();
});
},
});
let abort = false;
abortIndexing = () => {
abort = true;
};
for (let i = 0; i < chords.length; i++) {
if (abort) return index;
const chord = chords[i]!;
progress = i;
if ("phrase" in chord) {
console.log(encodeChord(chord, osLayout));
await index.addAsync(i, encodeChord(chord, osLayout));
}
}
return index;
}
const searchFilter = writable<number[] | undefined>(undefined);
async function search(index: FlexSearch.Index, event: Event) {
const query = (event.target as HTMLInputElement).value;
searchFilter.set(
query && searchIndex
? ((await index.searchAsync(query)) as number[])
: undefined,
);
page = 0;
}
function insertChord(actions: number[]) {
const id = JSON.stringify(actions)
if ($chords.some(it => JSON.stringify(it.actions) === id)) {
alert($LL.configure.chords.DUPLICATE())
return
const id = JSON.stringify(actions);
if ($chords.some((it) => JSON.stringify(it.actions) === id)) {
alert($LL.configure.chords.DUPLICATE());
return;
}
changes.update(changes => {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: actions,
actions,
phrase: [],
})
return changes
})
});
return changes;
});
}
function downloadVocabulary() {
const vocabulary = new Set(
$chords.map((it) =>
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "",
),
);
vocabulary.delete("");
const blob = new Blob([Array.from(vocabulary).join("|")], {
type: "text/plain",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vocabulary.txt";
a.click();
URL.revokeObjectURL(url);
}
const items = derived(
[searchFilter, chords],
([filter, chords]) =>
filter?.map(it => [chords[it], it] as const) ?? chords.map((it, i) => [it, i] as const),
)
filter?.map((it) => [chords[it], it] as const) ??
chords.map((it, i) => [it, i] as const),
);
const lastPage = derived(
[items, pageSize],
([items, pageSize]) => Math.ceil((items.length + 1) / pageSize) - 1,
)
);
setContext("cursor-crossfade", crossfade({}))
setContext("cursor-crossfade", crossfade({}));
let page = 0
let page = 0;
</script>
<svelte:head>
@@ -94,8 +222,9 @@
<div class="search-container">
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
on:input={search}
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
on:input={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length - 1}
/>
<div class="paginator">
{#if $lastPage !== -1}
@@ -104,35 +233,57 @@
- / -
{/if}
</div>
<button class="icon" on:click={() => (page = Math.max(page - 1, 0))} use:action={{shortcut: "ctrl+left"}}
>navigate_before</button
<button
class="icon"
on:click={() => (page = Math.max(page - 1, 0))}
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
>
<button
class="icon"
on:click={() => (page = Math.min(page + 1, $lastPage))}
use:action={{shortcut: "ctrl+right"}}>navigate_next</button
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
>
</div>
<section bind:this={results}>
<table>
{#if page === 0}
<tr
><th class="new-chord"><ChordActionEdit on:submit={({detail}) => insertChord(detail)} /></th><td /><td
/></tr
<!-- fixes some unresponsiveness -->
{#await tick() then}
<div class="results">
<table transition:fly={{ y: 48, easing: expoOut }}>
{#if $lastPage !== -1}
{#if page === 0}
<tr
><th class="new-chord"
><ChordActionEdit
on:submit={({ detail }) => insertChord(detail)}
/></th
><td /><td /></tr
>
{/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#if chord}
<tr>
<ChordEdit {chord} on:duplicate={() => (page = 0)} />
</tr>
{/if}
{/each}
{:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if}
</table>
</div>
<div class="sidebar">
<textarea
placeholder={$LL.configure.chords.TRY_TYPING() +
"\n\nDid you know? " +
randomTips[Math.floor(randomTips.length * Math.random())]}
></textarea>
<button on:click={downloadVocabulary}
><span class="icon">download</span>
{$LL.configure.chords.VOCABULARY()}</button
>
{/if}
{#if $lastPage !== -1}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord.id))}
<tr>
<ChordEdit {chord} />
</tr>
{/each}
{:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if}
</table>
<textarea placeholder={$LL.configure.chords.TRY_TYPING()}></textarea>
</div>
{/await}
</section>
<style lang="scss">
@@ -152,22 +303,42 @@
visibility: hidden;
}
.sidebar {
display: flex;
flex-direction: column;
> button {
padding-inline-start: 0;
}
}
textarea {
transition: border-color 250ms ease;
flex: 1;
transition: outline-color 250ms ease;
background: none;
color: inherit;
border: 1px dashed var(--md-sys-color-surface-variant);
border: 1px dashed var(--md-sys-color-outline);
outline: 2px solid transparent;
outline-offset: -1px;
margin: 2px;
padding: 8px;
border-radius: 4px;
&:focus {
outline: none;
border-color: var(--md-sys-color-primary);
outline-color: var(--md-sys-color-primary);
}
}
caption {
margin-top: 156px;
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
input[type="search"] {
@@ -185,15 +356,25 @@
transition: all 250ms ease;
@media (prefers-contrast: more) {
border-color: var(--md-sys-color-outline);
border-style: dashed;
}
&::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 0.2;
opacity: 0.8;
}
&:focus {
border-color: var(--md-sys-color-primary);
border-style: solid;
outline: none;
}
&.loading {
opacity: 0.4;
}
}
section {
@@ -208,10 +389,14 @@
border-radius: 16px;
}
.results {
height: 100%;
min-width: min(90vw, 16.5cm);
}
table {
height: fit-content;
overflow: hidden;
min-width: min(90vw, 16.5cm);
transition: all 1s ease;
}
</style>

View File

@@ -1,90 +1,139 @@
<script lang="ts">
import type {ChordInfo} from "$lib/undo-redo"
import {changes, ChangeType} from "$lib/undo-redo"
import {createEventDispatcher} from "svelte"
import LL from "../../../i18n/i18n-svelte"
import ActionString from "$lib/components/ActionString.svelte"
import {selectAction} from "./action-selector"
import {serialPort} from "$lib/serial/connection"
import {get} from "svelte/store"
import {inputToAction} from "./input-converter"
import type { ChordInfo } from "$lib/undo-redo";
import { changes, ChangeType } from "$lib/undo-redo";
import { createEventDispatcher } from "svelte";
import LL from "../../../i18n/i18n-svelte";
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
import { inputToAction } from "./input-converter";
export let chord: ChordInfo | undefined = undefined
export let chord: ChordInfo | undefined = undefined;
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher();
let pressedKeys = new Set<number>()
let editing = false
let pressedKeys = new Set<number>();
let editing = false;
function compare(a: number, b: number) {
return a - b
return a - b;
}
function makeChordInput(...actions: number[]) {
const compound = compoundIndices ?? [];
return [
...compound,
...Array.from(
{
length: 12 - (compound.length + actions.length + 1),
},
() => 0,
),
...actions.toSorted(compare),
];
}
function edit() {
pressedKeys = new Set()
editing = true
pressedKeys = new Set();
editing = true;
}
function keydown(event: KeyboardEvent) {
if (!editing) return
event.preventDefault()
pressedKeys.add(inputToAction(event, get(serialPort)?.device === "X")!)
pressedKeys = pressedKeys
// This is obviously a tradeoff
if (event.key === "Tab" || event.key === "Escape") return;
if (!editing) return;
event.preventDefault();
const input = inputToAction(event, get(serialPort)?.device === "X");
if (input == undefined) {
alert("Invalid key");
return;
}
pressedKeys.add(input);
pressedKeys = pressedKeys;
}
function keyup() {
if (!editing) return
editing = false
if (pressedKeys.size < 2) return
if (!chord) return dispatch("submit", [...pressedKeys].sort(compare))
changes.update(changes => {
if (!editing) return;
editing = false;
if (pressedKeys.size < 1) return;
if (!chord) return dispatch("submit", makeChordInput(...pressedKeys));
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: [...pressedKeys].sort(compare),
actions: makeChordInput(...pressedKeys),
phrase: chord!.phrase,
})
return changes
})
});
return changes;
});
return undefined;
}
function addSpecial(event: MouseEvent) {
selectAction(event, action => {
changes.update(changes => {
selectAction(event, (action) => {
changes.update((changes) => {
console.log(compoundIndices, chordActions, action);
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: [...chord!.actions, action].sort(compare),
actions: makeChordInput(...chordActions!, action),
phrase: chord!.phrase,
})
return changes
})
})
});
return changes;
});
});
}
$: chordActions = chord?.actions
.slice(chord.actions.lastIndexOf(0) + 1)
.toSorted(compare);
$: compoundIndices = chord?.actions.slice(0, chord.actions.indexOf(0));
</script>
<button
class:deleted={chord && chord.deleted}
class:edited={chord && chord.actionsChanged}
class:invalid={chord && chord.actions.toSorted(compare).some((it, i) => chord?.actions[i] !== it)}
class:invalid={chord &&
chordActions &&
(chordActions.length < 2 ||
chordActions.some((it, i) => chordActions[i] !== it))}
class="chord"
on:click={edit}
on:keydown={keydown}
on:keyup={keyup}
on:blur={keyup}
>
{#if editing && pressedKeys.size === 0}
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
{:else if !editing && !chord}
<span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if}
<ActionString display="keys" actions={editing ? [...pressedKeys].sort(compare) : chord?.actions ?? []} />
<button class="icon add" on:click|stopPropagation={addSpecial}>add_circle</button>
{#if !editing}
{#each compoundIndices ?? [] as index}
<sub>{index}</sub>
{/each}
{#if compoundIndices?.length}
<span>&rarr;</span>
{/if}
{/if}
<ActionString
display="keys"
actions={editing ? [...pressedKeys].sort(compare) : chordActions ?? []}
/>
<sup></sup>
<button class="icon add" on:click|stopPropagation={addSpecial}
>add_circle</button
>
</button>
<style lang="scss">
span {
opacity: 0.5;
@media (prefers-contrast: more) {
opacity: 0.8;
}
}
sup {
@@ -95,7 +144,6 @@
.add {
font-size: 18px;
margin-inline-start: 4px;
height: 20px;
opacity: 0;
--icon-fill: 1;
@@ -125,10 +173,10 @@
position: absolute;
top: 50%;
transform-origin: center left;
translate: -6px 0;
translate: -20px 0;
scale: 0 1;
width: calc(100% - 32px);
width: calc(100% - 60px);
height: 1px;
background: currentcolor;

View File

@@ -1,39 +1,70 @@
<script lang="ts">
import {changes, ChangeType} from "$lib/undo-redo.js"
import type {ChordInfo} from "$lib/undo-redo.js"
import ChordPhraseEdit from "./ChordPhraseEdit.svelte"
import ChordActionEdit from "./ChordActionEdit.svelte"
import type {Chord} from "$lib/serial/chord"
import {slide} from "svelte/transition"
import {charaFileToUriComponent} from "$lib/share/share-url"
import SharePopup from "../SharePopup.svelte"
import tippy from "tippy.js"
import { changes, ChangeType, chords } from "$lib/undo-redo.js";
import type { ChordInfo } from "$lib/undo-redo.js";
import ChordPhraseEdit from "./ChordPhraseEdit.svelte";
import ChordActionEdit from "./ChordActionEdit.svelte";
import type { Chord } from "$lib/serial/chord";
import { slide } from "svelte/transition";
import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte";
import tippy from "tippy.js";
import { createEventDispatcher } from "svelte";
export let chord: ChordInfo
export let chord: ChordInfo;
const dispatch = createEventDispatcher<{ duplicate: void }>();
function remove() {
changes.update(changes => {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase,
deleted: true,
})
return changes
})
});
return changes;
});
}
function isSameChord(a: Chord, b: Chord) {
return a.actions.length === b.actions.length && a.actions.every((it, i) => it === b.actions[i])
return (
a.actions.length === b.actions.length &&
a.actions.every((it, i) => it === b.actions[i])
);
}
function restore() {
changes.update(changes => changes.filter(it => !(it.type === ChangeType.Chord && isSameChord(it, chord))))
changes.update((changes) =>
changes.filter(
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
),
);
}
function duplicate() {
const id = [...chord.id];
id.splice(id.indexOf(0), 1);
id.push(0);
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
id[id.length - 1]++;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id,
actions: [...chord.actions],
phrase: [...chord.phrase],
});
return changes;
});
dispatch("duplicate");
}
async function share(event: Event) {
const url = new URL(window.location.href)
const url = new URL(window.location.href);
url.searchParams.set(
"import",
await charaFileToUriComponent({
@@ -41,21 +72,21 @@
type: "chords",
chords: [[chord.actions, chord.phrase]],
}),
)
await navigator.clipboard.writeText(url.toString())
let shareComponent: SharePopup
);
await navigator.clipboard.writeText(url.toString());
let shareComponent: SharePopup;
tippy(event.target as HTMLElement, {
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!
shareComponent = new SharePopup({target})
const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = new SharePopup({ target });
},
onHidden(instance) {
instance.destroy()
instance.destroy();
},
onDestroy(instance) {
shareComponent.$destroy()
onDestroy(_instance) {
shareComponent.$destroy();
},
}).show()
}).show();
}
</script>
@@ -67,11 +98,22 @@
</td>
<td class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" on:click={remove}>delete</button>
<button transition:slide class="icon compact" on:click={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" on:click={restore}>restore_from_trash</button>
<button transition:slide class="icon compact" on:click={restore}
>restore_from_trash</button
>
{/if}
<button class="icon compact" class:disabled={chord.isApplied} on:click={restore}>undo</button>
<button disabled={chord.deleted} class="icon compact" on:click={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
on:click={restore}>undo</button
>
<div class="separator" />
<button class="icon compact" on:click={share}>share</button>
</td>
@@ -87,6 +129,10 @@
background: currentcolor;
}
button {
transition: opacity 75ms ease;
}
td {
position: relative;
}

View File

@@ -1,105 +1,111 @@
<script lang="ts">
import {KEYMAP_IDS, specialKeycodes} from "$lib/serial/keymap-codes"
import {tick} from "svelte"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {changes, ChangeType} from "$lib/undo-redo"
import type {ChordInfo} from "$lib/undo-redo"
import {scale} from "svelte/transition"
import ActionString from "$lib/components/ActionString.svelte"
import {selectAction} from "./action-selector"
import {inputToAction} from "./input-converter"
import {serialPort} from "$lib/serial/connection"
import {get} from "svelte/store"
import { onMount, tick } from "svelte";
import { changes, ChangeType } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
import { scale } from "svelte/transition";
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { inputToAction } from "./input-converter";
import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
export let chord: ChordInfo
export let chord: ChordInfo;
onMount(() => {
if (chord.phrase.length === 0) {
box.focus();
}
});
function keypress(event: KeyboardEvent) {
if (event.key === "ArrowUp") {
addSpecial(event)
addSpecial(event);
} else if (event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1)
moveCursor(cursorPosition - 1);
} else if (event.key === "ArrowRight") {
moveCursor(cursorPosition + 1)
moveCursor(cursorPosition + 1);
} else if (event.key === "Backspace") {
deleteAction(cursorPosition - 1)
moveCursor(cursorPosition - 1)
deleteAction(cursorPosition - 1);
moveCursor(cursorPosition - 1);
} else if (event.key === "Delete") {
deleteAction(cursorPosition)
deleteAction(cursorPosition);
} else {
const action = inputToAction(event, get(serialPort)?.device === "X")
if (event.key === "Shift") return;
const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) {
insertAction(cursorPosition, action)
tick().then(() => moveCursor(cursorPosition + 1))
insertAction(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
}
}
}
function moveCursor(to: number) {
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length))
const item = box.children.item(cursorPosition) as HTMLElement
cursorOffset = item.offsetLeft + item.offsetWidth
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth;
}
function deleteAction(at: number, count = 1) {
if (!(at in chord.phrase)) return
changes.update(changes => {
if (!(at in chord.phrase)) return;
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, count),
})
return changes
})
});
return changes;
});
}
function insertAction(at: number, action: number) {
changes.update(changes => {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, 0, action),
})
return changes
})
});
return changes;
});
}
function clickCursor(event: MouseEvent) {
if (event.target === button) return
const distance = (event as unknown as {layerX: number}).layerX
if (event.target === button) return;
const distance = (event as unknown as { layerX: number }).layerX;
let i = 0
let i = 0;
for (const child of box.children) {
const {offsetLeft, offsetWidth} = child as HTMLElement
const { offsetLeft, offsetWidth } = child as HTMLElement;
if (distance < offsetLeft + offsetWidth / 2) {
moveCursor(i - 1)
return
moveCursor(i - 1);
return;
}
i++
i++;
}
moveCursor(i - 1)
moveCursor(i - 1);
}
function addSpecial(event: MouseEvent | KeyboardEvent) {
selectAction(
event,
action => {
insertAction(cursorPosition, action)
tick().then(() => moveCursor(cursorPosition + 1))
(action) => {
insertAction(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
},
() => box.focus(),
)
);
}
let button: HTMLButtonElement
let box: HTMLDivElement
let cursorPosition = 0
let cursorOffset = 0
let button: HTMLButtonElement;
let box: HTMLDivElement;
let cursorPosition = 0;
let cursorOffset = 0;
let hasFocus = false
let hasFocus = false;
</script>
<!-- svelte-ignore a11y-autofocus -->
<div
on:keydown={keypress}
on:mousedown={clickCursor}
@@ -108,8 +114,8 @@
bind:this={box}
class:edited={!chord.deleted && chord.phraseChanged}
on:focusin={() => (hasFocus = true)}
on:focusout={event => {
if (event.relatedTarget !== button) hasFocus = false
on:focusout={(event) => {
if (event.relatedTarget !== button) hasFocus = false;
}}
>
{#if hasFocus}

View File

@@ -1,50 +1,53 @@
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {tick} from "svelte"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { tick } from "svelte";
export function selectAction(
event: MouseEvent | KeyboardEvent,
select: (action: number) => void,
dismissed?: () => void,
) {
const component = new ActionSelector({target: document.body})
const dialog = document.querySelector("dialog > div") as HTMLDivElement
const backdrop = document.querySelector("dialog") as HTMLDialogElement
const dialogRect = dialog.getBoundingClientRect()
const groupRect = (event.target as HTMLElement).getBoundingClientRect()
const component = new ActionSelector({ target: document.body });
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect();
const groupRect = (event.target as HTMLElement).getBoundingClientRect();
const scale = 0.5
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
1 - scale * (1 - groupRect.height / dialogRect.height)
}`
const scale = 0.5;
const dialogScale = `${
1 - scale * (1 - groupRect.width / dialogRect.width)
} ${1 - scale * (1 - groupRect.height / dialogRect.height)}`;
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y)
}px`
}px`;
const duration = 150
const options = {duration, easing: "ease"}
const duration = 150;
const options = { duration, easing: "ease" };
const dialogAnimation = dialog.animate(
[
{scale: dialogScale, translate: dialogTranslate},
{translate: "0 0", scale: "1"},
{ scale: dialogScale, translate: dialogTranslate },
{ translate: "0 0", scale: "1" },
],
options,
)
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options)
);
const backdropAnimation = backdrop.animate(
[{ opacity: 0 }, { opacity: 1 }],
options,
);
async function closed() {
dialogAnimation.reverse()
backdropAnimation.reverse()
dialogAnimation.reverse();
backdropAnimation.reverse();
await dialogAnimation.finished
await dialogAnimation.finished;
component.$destroy()
await tick()
dismissed?.()
component.$destroy();
await tick();
dismissed?.();
}
component.$on("close", closed)
component.$on("select", ({detail}) => {
select(detail)
closed()
})
component.$on("close", closed);
component.$on("select", ({ detail }) => {
select(detail);
closed();
});
}

Some files were not shown because too many files have changed in this diff Show More