Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a134b970ee
|
|||
|
86476cfdd8
|
|||
|
742e7a6b98
|
|||
|
607338878b
|
|||
|
777488ecd1
|
|||
|
220c8cbe67
|
|||
|
42922e7ce0
|
|||
|
9c1918e683
|
|||
|
5014e1e8e8
|
|||
|
e0f5c6440c
|
|||
|
e21ff12804
|
|||
|
2fa8d93d60
|
|||
|
aa1d4787f5
|
|||
|
4cc9462655
|
|||
|
7d148d0c2c
|
|||
|
73c71836dc
|
|||
|
e508d1312e
|
|||
|
c709878d6a
|
|||
| 374e27c7d0 | |||
| 88c7f057c9 | |||
| 6b09cbfbec | |||
| 06c1121983 | |||
| 2130b6c7b9 | |||
| e64082d578 | |||
| 21dbfa48de | |||
| 7df75e109d | |||
| 5cdf969c6d | |||
| 634073f10d | |||
| 4cc3343984 | |||
| 998a400395 | |||
| c0fb737314 | |||
| c59b2732f7 | |||
| 9bf1a13e02 | |||
| 6facaad4a2 | |||
| b04ed7fe7f | |||
| 4eb1e8c049 | |||
| 26ca9984ea | |||
| 110771a2a4 | |||
| 7fdf1cd3b4 | |||
| c4fee59446 | |||
| 088aa0dbcf | |||
| 26a6f70ccb | |||
| 391c9d8837 | |||
| 3a167030da | |||
| e38e63222c | |||
| 7c74831647 |
62
.github/workflows/build.yml
vendored
@@ -2,48 +2,46 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
tags: ["v*"]
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
branches: ["master"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🔨 Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🚚 Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: 🐍 Use Python 3.x
|
||||
uses: actions/setup-python@v3.1.4
|
||||
with:
|
||||
python-version: 3.x
|
||||
cache: pip
|
||||
- name: ⏬ Install Python dependencies
|
||||
run: python -m venv venv
|
||||
- run: ./venv/bin/pip install -r requirements.txt
|
||||
|
||||
- name: 🐉 Use Node.js 18.16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.x
|
||||
cache: 'npm'
|
||||
- name: ⏬ Install Node dependencies
|
||||
run: npm ci
|
||||
- name: 🚚 Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: 🐍 Use Python 3.x
|
||||
uses: actions/setup-python@v3.1.4
|
||||
with:
|
||||
python-version: 3.x
|
||||
cache: pip
|
||||
- name: ⏬ Install Python dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: 🔥 Optimize icon font
|
||||
run: npm run minify-icons
|
||||
- name: 🔨 Build site
|
||||
run: npm run build
|
||||
|
||||
- name: 📦 Upload build artifacts
|
||||
uses: actions/upload-artifact@v3.1.2
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
- name: 🐉 Use Node.js 18.16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.x
|
||||
cache: "npm"
|
||||
- name: ⏬ Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🔥 Optimize icon font
|
||||
run: npm run minify-icons
|
||||
- name: 🔨 Build site
|
||||
run: npm run build
|
||||
|
||||
- name: 📦 Upload build artifacts
|
||||
uses: actions/upload-artifact@v3.1.2
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
|
||||
if: github.event_name == 'push' && contains(github.event.head_commit.message, '[deploy]')
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
|
||||
54
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: 'publish'
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, ubuntu-20.04, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- name: 🚚 Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: 🐍 Use Python 3.x
|
||||
uses: actions/setup-python@v3.1.4
|
||||
with:
|
||||
python-version: 3.x
|
||||
cache: pip
|
||||
- name: ⏬ Install Python dependencies
|
||||
run: pip install -r requirements.txt
|
||||
- name: 🐉 Use Node.js 18.16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.x
|
||||
cache: "npm"
|
||||
- name: 🦀 Use Rust Stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: 🐧 Install Linux Dependencies
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libudev-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: ⏬ Install Node dependencies
|
||||
run: npm ci
|
||||
- name: 🔥 Optimize icon font
|
||||
run: npm run minify-icons
|
||||
- name: 📦 Build, Package & Release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
with:
|
||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
|
||||
releaseName: 'App v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
@@ -11,3 +11,5 @@ node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
static/languages/*.json
|
||||
|
||||
5
.typesafe-i18n.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/typesafe-i18n@5.26.0/schema/typesafe-i18n.json",
|
||||
"baseLocale": "en",
|
||||
"adapter": "svelte"
|
||||
}
|
||||
19
README.md
@@ -1,4 +1,4 @@
|
||||
# dot i/o V2
|
||||
# amaCC1ng
|
||||
|
||||

|
||||

|
||||
@@ -6,13 +6,28 @@
|
||||
|
||||
_This project is not affiliated or endorsed with neither the original [dot i/o](https://www.iq-eq.io/) site, nor [CharaChorder](https://www.charachorder.com/)_
|
||||
|
||||
Get the latest desktop release [here](https://github.com/Theaninova/dotio/releases).
|
||||
|
||||
I aim to create a new site that offers an easier, visually pleasing
|
||||
and more complete way to configure and learn CharaChorder devices.
|
||||
|
||||
## Development
|
||||
|
||||
### Nix
|
||||
|
||||
[Enable flakes](https://nixos.wiki/wiki/Flakes#Enable_flakes), then start the development shell using
|
||||
|
||||
```shell
|
||||
nix develop
|
||||
```
|
||||
|
||||
You may need to run through some additional setup to get Rust running inside IntelliJ.
|
||||
|
||||
### Other platforms
|
||||
|
||||
- NodeJS >=18.16
|
||||
- Python >=3.10 virtual environment
|
||||
- Python >=3.10
|
||||
- Rust Stable (For Tauri Development)
|
||||
|
||||
I know, python in JS projects is extremely annoying, unfortunately,
|
||||
it seems to be the only platform that offers a functional
|
||||
|
||||
130
flake.lock
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1689752456,
|
||||
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681358109,
|
||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1690942540,
|
||||
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
54
flake.nix
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-std" "clippy" "rust-analyzer" ];
|
||||
};
|
||||
fontMin = (pkgs.python311.withPackages(ps: with ps; [ brotli fonttools ] ++ (with fonttools.optional-dependencies; [ woff ])));
|
||||
tauriPkgs = nixpkgs.legacyPackages.${system};
|
||||
libraries = with tauriPkgs; [
|
||||
webkitgtk
|
||||
gtk3
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
dbus
|
||||
openssl_3
|
||||
librsvg
|
||||
];
|
||||
packages = (with pkgs; [
|
||||
nodejs_18
|
||||
rust-bin
|
||||
fontMin
|
||||
]) ++ (with tauriPkgs; [
|
||||
curl
|
||||
wget
|
||||
pkg-config
|
||||
dbus
|
||||
openssl_3
|
||||
glib
|
||||
gtk3
|
||||
libsoup
|
||||
webkitgtk
|
||||
librsvg
|
||||
# serial plugin
|
||||
udev
|
||||
]);
|
||||
in
|
||||
{
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = packages;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const config: IconsConfig = {
|
||||
"piano",
|
||||
"keyboard",
|
||||
"settings",
|
||||
"edit",
|
||||
"music_note",
|
||||
"avg_pace",
|
||||
"lyrics",
|
||||
@@ -26,6 +27,7 @@ const config: IconsConfig = {
|
||||
"sync",
|
||||
"restart_alt",
|
||||
"usb",
|
||||
"usb_off",
|
||||
"rule_settings",
|
||||
"123",
|
||||
"abc",
|
||||
@@ -33,6 +35,7 @@ const config: IconsConfig = {
|
||||
"cloud_done",
|
||||
"backup",
|
||||
"cloud_download",
|
||||
"cloud_off",
|
||||
"share",
|
||||
"ios_share",
|
||||
"close",
|
||||
@@ -40,6 +43,22 @@ const config: IconsConfig = {
|
||||
"arrow_back_ios_new",
|
||||
"save",
|
||||
"settings_backup_restore",
|
||||
"sort",
|
||||
"filter_list",
|
||||
"settings_power",
|
||||
"link",
|
||||
"link_off",
|
||||
"chevron_right",
|
||||
"check_circle",
|
||||
"error",
|
||||
"auto_delete",
|
||||
"format_paint",
|
||||
"dark_mode",
|
||||
"light_mode",
|
||||
"palette",
|
||||
"translate",
|
||||
"play_arrow",
|
||||
"extension",
|
||||
],
|
||||
codePoints: {
|
||||
speed: "e9e4",
|
||||
@@ -50,6 +69,7 @@ const config: IconsConfig = {
|
||||
counter_2: "f783",
|
||||
counter_3: "f782",
|
||||
ios_share: "e6b8",
|
||||
light_mode: "e518",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
3432
package-lock.json
generated
88
package.json
@@ -1,52 +1,74 @@
|
||||
{
|
||||
"name": "cccs",
|
||||
"version": "0.1.0",
|
||||
"name": "amacc1ng",
|
||||
"version": "0.4.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"dev": "npm-run-all --parallel vite typesafe-i18n",
|
||||
"dev:tauri": "tauri dev",
|
||||
"vite": "vite dev",
|
||||
"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",
|
||||
"minify-icons": "ts-node-esm src/tools/minify-icon-font.ts",
|
||||
"lint": "prettier --plugin-search-dir . --check .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.9.0",
|
||||
"@codemirror/commands": "^6.2.4",
|
||||
"@codemirror/lang-javascript": "^6.1.9",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.2.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.0.6",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.7",
|
||||
"@material/material-color-utilities": "^0.2.7",
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
"@sveltejs/adapter-static": "^2.0.3",
|
||||
"@sveltejs/kit": "^1.22.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.3",
|
||||
"@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",
|
||||
"@vite-pwa/sveltekit": "^0.2.5",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.4",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.0.4",
|
||||
"stylelint": "^15.9.0",
|
||||
"stylelint-config-standard-scss": "^10.0.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recommended-scss": "^12.0.0",
|
||||
"stylelint-config-clean-order": "^5.0.1",
|
||||
"glob": "^10.3.1",
|
||||
"flexsearch": "^0.7.31",
|
||||
"@sveltejs/adapter-static": "^2.0.2",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.2",
|
||||
"jsdom": "^22.1.0",
|
||||
"@material/material-color-utilities": "^0.2.7",
|
||||
"fontkit": "^2.0.2",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte-check": "^3.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^0.33.0",
|
||||
"vite": "^4.3.6",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"sass": "^1.63.6"
|
||||
"codemirror": "^6.0.1",
|
||||
"cypress": "^12.17.3",
|
||||
"flexsearch": "^0.7.31",
|
||||
"fontkit": "^2.0.2",
|
||||
"glob": "^10.3.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.0.1",
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"sass": "^1.64.2",
|
||||
"stylelint": "^15.10.2",
|
||||
"stylelint-config-clean-order": "^5.0.1",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^12.0.0",
|
||||
"stylelint-config-standard-scss": "^10.0.0",
|
||||
"svelte": "^4.1.2",
|
||||
"svelte-check": "^3.4.6",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"ts-node": "^10.9.1",
|
||||
"typesafe-i18n": "^5.26.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.8",
|
||||
"vite-plugin-mkcert": "^1.16.0",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vitest": "^0.34.1"
|
||||
},
|
||||
"type": "module",
|
||||
"prettier": "@theaninova/prettier-config"
|
||||
|
||||
21
patches/@types+flexsearch+0.7.3.patch
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
153
patches/flexsearch+0.7.31.patch
Normal file
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
3
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
4075
src-tauri/Cargo.lock
generated
Normal file
27
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.4.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
repository = "https://github.com/Theaninova/dotio"
|
||||
default-run = "app"
|
||||
edition = "2021"
|
||||
rust-version = "1.60"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serialport = "4.2.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.4.0", features = ["updater"] }
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
11
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod serial;
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(serial::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
99
src-tauri/src/serial.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use serde::Serialize;
|
||||
use serialport::{available_ports, SerialPort, SerialPortType};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::plugin::{Builder, TauriPlugin};
|
||||
use tauri::{command, generate_handler, Manager, Runtime, State};
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("serial")
|
||||
.invoke_handler(generate_handler![
|
||||
get_serial_ports,
|
||||
open,
|
||||
close,
|
||||
read,
|
||||
write
|
||||
])
|
||||
.setup(move |app_handle| {
|
||||
app_handle.manage(SerialState::default());
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SerialState {
|
||||
handles: Arc<Mutex<HashMap<String, Box<dyn SerialPort>>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebSerialPortInfo {
|
||||
pub name: String,
|
||||
pub usb_product_id: u16,
|
||||
pub usb_vendor_id: u16,
|
||||
pub serial_number: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub product: Option<String>,
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn get_serial_ports() -> Result<Vec<WebSerialPortInfo>, String> {
|
||||
Ok(available_ports()
|
||||
.map_err(|err| err.to_string())?
|
||||
.iter()
|
||||
.filter_map(|port| match &port.port_type {
|
||||
SerialPortType::UsbPort(usb) => Some(WebSerialPortInfo {
|
||||
name: port.port_name.clone(),
|
||||
usb_vendor_id: usb.vid,
|
||||
usb_product_id: usb.pid,
|
||||
serial_number: usb.serial_number.clone(),
|
||||
manufacturer: usb.manufacturer.clone(),
|
||||
product: usb.product.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn open(state: State<'_, SerialState>, path: String, baud_rate: u32) -> Result<(), String> {
|
||||
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
|
||||
if handles.contains_key(&path) {
|
||||
return Ok(());
|
||||
}
|
||||
let port = serialport::new(path.clone(), baud_rate)
|
||||
.open()
|
||||
.map_err(|err| err.to_string())?;
|
||||
handles.insert(path, port);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn close(state: State<'_, SerialState>, path: String) -> Result<(), String> {
|
||||
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
|
||||
handles.remove(&path).ok_or("Port is already closed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn read(state: State<'_, SerialState>, path: String) -> Result<Vec<u8>, String> {
|
||||
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
|
||||
let port = handles.get_mut(&path).ok_or("Read: Port is not open")?;
|
||||
|
||||
let size = port.bytes_to_read().map_err(|err| err.to_string())?;
|
||||
let mut buffer: Vec<u8> = vec![0; size as usize];
|
||||
port.read_exact(buffer.as_mut_slice())
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn write(state: State<'_, SerialState>, path: String, chunk: Vec<u8>) -> Result<(), String> {
|
||||
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
|
||||
let port = handles.get_mut(&path).ok_or("Write: Port is not open")?;
|
||||
port.write_all(&chunk).map_err(|err| err.to_string())
|
||||
}
|
||||
72
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": {
|
||||
"productName": "amacc1ng",
|
||||
"version": "0.4.1"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "AGPL-3.0-or-later",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "de.theaninova.chara-app",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://amacc1ng.theaninova.de/update?current_version={{current_version}}&target={{target}}&arch={{arch}}",
|
||||
"https://dotio.theaninova.de/update?current_version={{current_version}}&target={{target}}&arch={{arch}}"
|
||||
],
|
||||
"dialog": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5QjEwMEY5RjNBRjM4MEIKUldRTE9LL3orUUN4V2FMWDZkc2l2VUdOL3FSdUMwTk1ualNac095RVZXVEpqUEtORkFsWGZaTmsK"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 720,
|
||||
"resizable": true,
|
||||
"title": "amacc1ng",
|
||||
"width": 1280
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
14
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <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
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
1
src/i18n/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
i18n-*.ts
|
||||
67
src/i18n/de/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {Translation} from "../i18n-types"
|
||||
|
||||
const de = {
|
||||
TITLE: "amaCC1ng",
|
||||
backup: {
|
||||
TITLE: "Sicherungskopie",
|
||||
DISCLAIMER:
|
||||
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.",
|
||||
DOWNLOAD: "Kopie Speichern",
|
||||
RESTORE: "Wiederherstellen",
|
||||
},
|
||||
profile: {
|
||||
TITLE: "Profil",
|
||||
LANGUAGE: "Sprache",
|
||||
theme: {
|
||||
TITLE: "Darstellung",
|
||||
COLOR_SCHEME: "Farbschema",
|
||||
DARK_MODE: "Dunkel",
|
||||
LIGHT_MODE: "Hell",
|
||||
},
|
||||
},
|
||||
deviceManager: {
|
||||
TITLE: "Gerät",
|
||||
AUTO_CONNECT: "Automatisch Verbinden",
|
||||
CONNECT: "Verbinden",
|
||||
DISCONNECT: "Entfernen",
|
||||
TERMINAL: "Konsole",
|
||||
bootMenu: {
|
||||
TITLE: "Bootmenü",
|
||||
REBOOT: "Neustarten",
|
||||
BOOTLOADER: "Bootloader",
|
||||
},
|
||||
},
|
||||
browserWarning: {
|
||||
TITLE: "Warnung",
|
||||
INFO_SERIAL_PREFIX:
|
||||
"Der aktuell genutzte Browser wird aufgrund der speziellen Voraussetzung für Kommunikation über die ",
|
||||
INFO_SERIAL_INFIX: "serielle Schnittstelle",
|
||||
INFO_SERIAL_SUFFIX: " nicht unterstützt.",
|
||||
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.",
|
||||
DOWNLOAD_APP: "Desktop-app herunterladen",
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Akkorde",
|
||||
search: {
|
||||
PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
TITLE: "Layout",
|
||||
},
|
||||
settings: {
|
||||
TITLE: "Einstellungen",
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
editor: {
|
||||
RUN: "Ausführen",
|
||||
},
|
||||
},
|
||||
} satisfies Translation
|
||||
|
||||
export default de
|
||||
65
src/i18n/en/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {BaseTranslation} from "../i18n-types"
|
||||
|
||||
const en = {
|
||||
TITLE: "amaCC1ng",
|
||||
backup: {
|
||||
TITLE: "Local Backup",
|
||||
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.",
|
||||
DOWNLOAD: "Download Backup",
|
||||
RESTORE: "Restore",
|
||||
},
|
||||
profile: {
|
||||
TITLE: "Profile",
|
||||
LANGUAGE: "Language",
|
||||
theme: {
|
||||
TITLE: "Theme",
|
||||
COLOR_SCHEME: "Color scheme",
|
||||
DARK_MODE: "Dark",
|
||||
LIGHT_MODE: "Light",
|
||||
},
|
||||
},
|
||||
deviceManager: {
|
||||
TITLE: "Device",
|
||||
AUTO_CONNECT: "Auto-connect",
|
||||
CONNECT: "Connect",
|
||||
DISCONNECT: "Disconnect",
|
||||
TERMINAL: "Terminal",
|
||||
bootMenu: {
|
||||
TITLE: "Boot Menu",
|
||||
REBOOT: "Reboot",
|
||||
BOOTLOADER: "Bootloader",
|
||||
},
|
||||
},
|
||||
browserWarning: {
|
||||
TITLE: "Warning",
|
||||
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: "Download the desktop app",
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Chords",
|
||||
search: {
|
||||
PLACEHOLDER: "Search {0} chord{{|s}}",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
TITLE: "Layout",
|
||||
},
|
||||
settings: {
|
||||
TITLE: "Settings",
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
editor: {
|
||||
RUN: "Run",
|
||||
},
|
||||
},
|
||||
} satisfies BaseTranslation
|
||||
|
||||
export default en
|
||||
11
src/i18n/formatters.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FormattersInitializer } from 'typesafe-i18n'
|
||||
import type { Locales, Formatters } from './i18n-types'
|
||||
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
|
||||
|
||||
const formatters: Formatters = {
|
||||
// add your formatter functions here
|
||||
}
|
||||
|
||||
return formatters
|
||||
}
|
||||
59
src/lib/action-autocomplete.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {Action} from "svelte/action"
|
||||
import Index from "flexsearch"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import tippy from "tippy.js"
|
||||
import ActionAutocomplete from "$lib/components/ActionAutocomplete.svelte"
|
||||
import {browser} from "$app/environment"
|
||||
|
||||
const index = browser ? new Index({tokenize: "full"}) : undefined
|
||||
for (const action of Object.values(KEYMAP_CODES)) {
|
||||
index?.add(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
)
|
||||
}
|
||||
const exact = Object.fromEntries(
|
||||
Object.values(KEYMAP_CODES)
|
||||
.filter(it => !!it.id)
|
||||
.map(it => [it.id, it] as const),
|
||||
)
|
||||
|
||||
export const actionAutocomplete: Action<HTMLInputElement> = node => {
|
||||
if (!browser) return
|
||||
|
||||
let completionComponent: ActionAutocomplete
|
||||
const completionDialog = tippy(node, {
|
||||
interactive: true,
|
||||
placement: "bottom-start",
|
||||
hideOnClick: false,
|
||||
theme: "surface-variant search-completion",
|
||||
arrow: false,
|
||||
trigger: "focus",
|
||||
offset: [0, 0],
|
||||
onCreate(instance) {
|
||||
const target = instance.popper.querySelector(".tippy-content")!
|
||||
completionComponent = new ActionAutocomplete({target, props: {width: node.clientWidth}})
|
||||
},
|
||||
onDestroy() {
|
||||
completionComponent.$destroy()
|
||||
},
|
||||
})
|
||||
|
||||
function input(event: Event) {
|
||||
completionComponent.$set({
|
||||
results: index!.search(node.value),
|
||||
exact: exact[node.value],
|
||||
code: Number(node.value),
|
||||
})
|
||||
}
|
||||
|
||||
node.addEventListener("input", input)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("input", input)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Action Codes
|
||||
description: 10-bit action codes 0x00-0x1F
|
||||
actions:
|
||||
0x00:
|
||||
id: "0x00"
|
||||
0x01:
|
||||
id: "0x01"
|
||||
0x02:
|
||||
id: "0x02"
|
||||
0x03:
|
||||
id: "0x03"
|
||||
0x04:
|
||||
id: "0x04"
|
||||
0x05:
|
||||
id: "0x05"
|
||||
0x06:
|
||||
id: "0x06"
|
||||
0x07:
|
||||
id: "0x07"
|
||||
0x08:
|
||||
id: "0x08"
|
||||
0x09:
|
||||
id: "0x09"
|
||||
0x0A:
|
||||
id: "0x0A"
|
||||
0x0B:
|
||||
id: "0x0B"
|
||||
0x0C:
|
||||
id: "0x0C"
|
||||
0x0D:
|
||||
id: "0x0D"
|
||||
0x0E:
|
||||
id: "0x0E"
|
||||
0x0F:
|
||||
id: "0x0F"
|
||||
0x10:
|
||||
id: "0x10"
|
||||
0x11:
|
||||
id: "0x11"
|
||||
0x12:
|
||||
id: "0x12"
|
||||
0x13:
|
||||
id: "0x13"
|
||||
0x14:
|
||||
id: "0x14"
|
||||
0x15:
|
||||
id: "0x15"
|
||||
0x16:
|
||||
id: "0x16"
|
||||
0x17:
|
||||
id: "0x17"
|
||||
0x18:
|
||||
id: "0x18"
|
||||
0x19:
|
||||
id: "0x19"
|
||||
0x1A:
|
||||
id: "0x1A"
|
||||
0x1B:
|
||||
id: "0x1B"
|
||||
0x1C:
|
||||
id: "0x1C"
|
||||
0x1D:
|
||||
id: "0x1D"
|
||||
0x1E:
|
||||
id: "0x1E"
|
||||
0x1F:
|
||||
id: "0x1F"
|
||||
@@ -292,4 +292,4 @@ actions:
|
||||
127:
|
||||
id: "DEL"
|
||||
title: Delete
|
||||
icon: delete_forever
|
||||
icon: delete_forever
|
||||
|
||||
@@ -930,4 +930,4 @@ actions:
|
||||
description: Not required to be supported by any OS.
|
||||
511:
|
||||
id: "KSC_FF"
|
||||
description: Not required to be supported by any OS.
|
||||
description: Not required to be supported by any OS.
|
||||
|
||||
36
src/lib/assets/layouts/cc1.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CC1
|
||||
col:
|
||||
- gap: 156
|
||||
row:
|
||||
- row:
|
||||
- {d: 30, e: 31, n: 32, w: 33, s: 34}
|
||||
- col:
|
||||
- {d: 25, e: 26, n: 27, w: 28, s: 29}
|
||||
- {d: 40, e: 41, n: 42, w: 43, s: 44}
|
||||
- col:
|
||||
- {d: 20, e: 21, n: 22, w: 23, s: 24}
|
||||
- {d: 35, e: 36, n: 37, w: 38, s: 39}
|
||||
- {d: 15, e: 16, n: 17, w: 18, s: 19}
|
||||
- row:
|
||||
- {d: 60, w: 61, n: 62, e: 63, s: 64}
|
||||
- col:
|
||||
- {d: 65, w: 66, n: 67, e: 68, s: 69}
|
||||
- {d: 80, w: 81, n: 82, e: 83, s: 84}
|
||||
- col:
|
||||
- {d: 70, w: 71, n: 72, e: 73, s: 74}
|
||||
- {d: 85, w: 86, n: 87, e: 88, s: 89}
|
||||
- {d: 75, w: 76, n: 77, e: 78, s: 79}
|
||||
- gap: 48
|
||||
margin-top: -32
|
||||
row:
|
||||
- {d: 10, e: 11, n: 12, w: 13, s: 14}
|
||||
- {d: 55, w: 56, n: 57, e: 58, s: 59}
|
||||
- gap: 160
|
||||
row:
|
||||
- {d: 5, e: 6, n: 7, w: 8, s: 9}
|
||||
- {d: 50, w: 51, n: 52, e: 53, s: 54}
|
||||
- gap: 320
|
||||
margin-top: -12
|
||||
row:
|
||||
- {d: 0, e: 1, n: 2, w: 3, s: 4}
|
||||
- {d: 45, w: 46, n: 47, e: 48, s: 49}
|
||||
23
src/lib/chords/coverage.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
|
||||
interface Language {
|
||||
name: string
|
||||
noLazyMode?: boolean
|
||||
orderedByFrequency?: boolean
|
||||
words: string[]
|
||||
}
|
||||
|
||||
export async function calculateChordCoverage(chords: Chord[]) {
|
||||
const language: Language = await fetch("/languages/english.json").then(it => it.json())
|
||||
|
||||
const words = new Set(language.words)
|
||||
for (const chord of chords) {
|
||||
words.delete(chord.phrase.map(it => KEYMAP_CODES[it].id!).join(""))
|
||||
}
|
||||
|
||||
return {
|
||||
coverage: words.size / language.words.length,
|
||||
missing: [...words.values()],
|
||||
}
|
||||
}
|
||||
64
src/lib/components/ActionAutocomplete.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
||||
|
||||
export let exact: number | undefined = undefined
|
||||
export let code: number = Number.NaN
|
||||
export let results: number[] = []
|
||||
|
||||
export let width: number
|
||||
</script>
|
||||
|
||||
<div class="list" style="width: {width}px">
|
||||
{#if exact !== undefined}
|
||||
<div class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<button>USE CODE</button>
|
||||
{:else}
|
||||
<div>Action code is out of range</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as id (id)}
|
||||
<ActionListItem {id} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.list {
|
||||
--scrollbar-color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
scrollbar-gutter: stable both-edges;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
max-height: 500px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
> i {
|
||||
padding-inline: 8px;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
71
src/lib/components/ActionListItem.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
|
||||
export let id: number | KeyInfo
|
||||
|
||||
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
|
||||
</script>
|
||||
|
||||
<button>
|
||||
{#if typeof key === "object"}
|
||||
<div class="title">
|
||||
<b>
|
||||
{key.title || ""}
|
||||
{#if key.variant === "left"}
|
||||
(Left)
|
||||
{:else if key.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
</b>
|
||||
{#if key.description}
|
||||
<i>{key.description}</i>
|
||||
{/if}
|
||||
</div>
|
||||
<span class:icon={!!key.icon} class="key">{key.icon || key.id || `0x${key.code.toString(16)}`}</span>
|
||||
{:else}
|
||||
<span class="key">0x{key.toString(16)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 32px;
|
||||
padding: 4px;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
77
src/lib/components/TypingInput.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
|
||||
import {chords, highlightActions} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
|
||||
|
||||
$: content = Array.from({length: 10}).map(() => $chords[Math.floor(Math.random() * $chords.length)])
|
||||
|
||||
let cursor = [0, 0]
|
||||
let input = []
|
||||
|
||||
$: {
|
||||
$highlightActions = content[cursor[0]]?.actions ?? []
|
||||
}
|
||||
|
||||
function keypress(event: KeyboardEvent) {
|
||||
cursor++
|
||||
input.push(event.key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keypress={keypress} />
|
||||
|
||||
<div>
|
||||
<section>
|
||||
<!-- <div class="cursor" style="translate: calc({cursor}ch - 50%) -50%" /> -->
|
||||
{#each content as word, i}
|
||||
{#if word}
|
||||
{#each word.phrase as letter, j}
|
||||
<span>{KEYMAP_CODES[letter].id}</span>
|
||||
{/each}
|
||||
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<LayoutCC1 />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.letter {
|
||||
position: relative;
|
||||
filter: brightness(50%);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
translate: -50% -50%;
|
||||
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
</style>
|
||||
167
src/lib/components/layout/ActionSelector.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
|
||||
import charaActions from "$lib/assets/keymaps/chara-chorder.yml"
|
||||
import mouseActions from "$lib/assets/keymaps/mouse.yml"
|
||||
import keyboardActions from "$lib/assets/keymaps/keyboard.yml"
|
||||
import asciiActions from "$lib/assets/keymaps/ascii.yml"
|
||||
import cp1252Actions from "$lib/assets/keymaps/cp-1252.yml"
|
||||
import FlexSearch from "flexsearch"
|
||||
|
||||
const index = new FlexSearch({tokenize: "full"})
|
||||
|
||||
for (const code in KEYMAP_CODES) {
|
||||
const key = KEYMAP_CODES[code]
|
||||
index.add(
|
||||
code,
|
||||
`${key.id || key.code} ${key.title || ""} ${key.variant || ""} ${key.description || ""}`.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function search() {
|
||||
const query = searchInput.value
|
||||
customValue = query && !Number.isNaN(Number(query)) ? Number(query) : undefined
|
||||
results = query ? index.search(searchInput.value) : defaultActions
|
||||
}
|
||||
|
||||
let customValue = undefined
|
||||
const defaultActions: string[] = [
|
||||
charaActions,
|
||||
mouseActions,
|
||||
keyboardActions,
|
||||
asciiActions,
|
||||
cp1252Actions,
|
||||
].flatMap(it => Object.keys(it.actions))
|
||||
let results: string[] = defaultActions
|
||||
let searchInput: HTMLInputElement
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<input type="search" on:input={search} placeholder="Search Actions" bind:this={searchInput} />
|
||||
|
||||
<div class="results">
|
||||
{#if customValue !== undefined}
|
||||
<button class="custom">
|
||||
Custom ActionID
|
||||
<span class="key">0x{customValue.toString(16).toUpperCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#each results as id}
|
||||
{@const key = KEYMAP_CODES[id]}
|
||||
<button title={key.description}>
|
||||
<div class="title">
|
||||
<b>
|
||||
{key.title || ""}
|
||||
{#if key.variant === "left"}
|
||||
(Left)
|
||||
{:else if key.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
</b>
|
||||
{#if key.description}
|
||||
<i>{key.description}</i>
|
||||
{/if}
|
||||
</div>
|
||||
<span class:icon={!!key.icon} class="key">{key.icon || key.id || key.code}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
width: calc(min(100vw - 10px, 512px));
|
||||
height: calc(min(90vh, 600px));
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-size: 18px;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
|
||||
&::placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "plus";
|
||||
}
|
||||
}
|
||||
|
||||
.key {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
|
||||
text-align: start;
|
||||
|
||||
> b {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.custom {
|
||||
padding: 8px;
|
||||
padding-inline-start: 16px;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/layout/InputEdit.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import {layout} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
|
||||
import {popup} from "$lib/popup"
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
||||
|
||||
export let id: number = 0
|
||||
</script>
|
||||
|
||||
<table>
|
||||
{#each $layout as layer, i}
|
||||
<tr>
|
||||
<th class="icon">counter_{i + 1}</th>
|
||||
<ActionListItem id={layer[id]} />
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
95
src/lib/components/layout/Layout.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import {serialPort} from "$lib/serial/connection"
|
||||
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
|
||||
|
||||
$: device = $serialPort?.device ?? "ONE"
|
||||
let activeLayer = 0
|
||||
|
||||
const layers = [
|
||||
["Numeric Layer", "123", 1],
|
||||
["Primary Layer", "abc", 0],
|
||||
["Function Layer", "function", 2],
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<select bind:value={device}>
|
||||
<option value="ONE">CC1</option>
|
||||
<option value="LITE">Lite</option>
|
||||
</select>
|
||||
|
||||
<fieldset>
|
||||
{#each layers as [title, icon, value]}
|
||||
<button
|
||||
{title}
|
||||
class="icon"
|
||||
on:click={() => (activeLayer = value)}
|
||||
class:active={activeLayer === value}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#if device === "ONE"}
|
||||
<LayoutCC1 bind:activeLayer />
|
||||
{:else}
|
||||
<p>Unsupported device ({$serialPort?.device})</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
fieldset {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-block-end: -36px;
|
||||
padding: 0;
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
cursor: pointer;
|
||||
|
||||
z-index: 1;
|
||||
|
||||
font-size: 24px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
border: none;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:nth-child(2) {
|
||||
z-index: 2;
|
||||
|
||||
aspect-ratio: 1;
|
||||
|
||||
font-size: 32px;
|
||||
|
||||
border-radius: 50%;
|
||||
outline: 8px solid var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-inline-end: 16px;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-inline-start: 16px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 900;
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,9 @@
|
||||
<script>
|
||||
import RingInput from "$lib/components/RingInput.svelte"
|
||||
import RingInput from "$lib/components/layout/RingInput.svelte"
|
||||
|
||||
let activeLayer = 0
|
||||
export let activeLayer = 0
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
{#each [["Numeric Layer", "123", 1], ["Primary Layer", "abc", 0], ["Function Layer", "function", 2]] as [title, icon, value]}
|
||||
<button {title} class="icon" on:click={() => (activeLayer = value)} class:active={activeLayer === value}>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
<div class="col layout" style="gap: 0">
|
||||
<div class="row" style="gap: 156px">
|
||||
<div class="row">
|
||||
@@ -55,60 +47,6 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
fieldset {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-block-end: -36px;
|
||||
padding: 0;
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
cursor: pointer;
|
||||
|
||||
z-index: 1;
|
||||
|
||||
font-size: 24px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
border: none;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:nth-child(2) {
|
||||
z-index: 2;
|
||||
|
||||
aspect-ratio: 1;
|
||||
|
||||
font-size: 32px;
|
||||
|
||||
border-radius: 50%;
|
||||
outline: 8px solid var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-inline-end: 16px;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-inline-start: 16px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 900;
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.row,
|
||||
.col {
|
||||
display: flex;
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {layout} from "$lib/serial/connection"
|
||||
import type {CharaLayout} from "$lib/serial/connection"
|
||||
import {highlightActions, layout} from "$lib/serial/connection"
|
||||
import type {CharaLayout} from "$lib/serialization/layout"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import {editableLayout} from "$lib/editable-layout"
|
||||
|
||||
export let activeLayer = 0
|
||||
export let keys: Record<"d" | "n" | "w" | "e", number>
|
||||
export let keys: Record<"d" | "s" | "n" | "w" | "e", number>
|
||||
export let type: "primary" | "secondary" | "tertiary" = "primary"
|
||||
|
||||
const layerNames = ["Primary Layer", "Number Layer", "Function Layer"]
|
||||
@@ -19,13 +20,6 @@
|
||||
return 25 * quadrant + layerOffsetIndex * layerOffset
|
||||
}
|
||||
|
||||
function getKeyDescriptions(keys: KeyInfo[]): string {
|
||||
return keys
|
||||
.filter(it => !!it)
|
||||
.map(({title, id, code}, i) => `${title || id || code} (${layerNames[i]})`)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function getActions(id: number, layout: CharaLayout): KeyInfo[] {
|
||||
return Array.from({length: 3}).map((_, i) => {
|
||||
const actionId = layout?.[i][id]
|
||||
@@ -35,9 +29,12 @@
|
||||
</script>
|
||||
|
||||
<div class="radial {type}">
|
||||
{#each [keys.n, keys.e, keys.s, keys.w] as id, quadrant}
|
||||
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
|
||||
{@const actions = getActions(id, $layout)}
|
||||
<button title={getKeyDescriptions(actions)}>
|
||||
<button
|
||||
use:editableLayout={{id, quadrant}}
|
||||
class:active={actions.some(it => it && $highlightActions?.includes(it.code))}
|
||||
>
|
||||
{#each actions as keyInfo, layer}
|
||||
{#if keyInfo}
|
||||
<span
|
||||
@@ -136,6 +133,7 @@
|
||||
mask-image: url("$lib/assets/quater-ring.svg");
|
||||
mask-size: 100% 100%;
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
@@ -156,6 +154,21 @@
|
||||
&:nth-child(4) {
|
||||
clip-path: polygon(50% 50%, 0 0, 0 100%);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 25cqw;
|
||||
height: 25cqh;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary > button {
|
||||
32
src/lib/editable-layout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import tippy from "tippy.js"
|
||||
import InputEdit from "$lib/components/layout/InputEdit.svelte"
|
||||
import type {Action} from "svelte/action"
|
||||
|
||||
export const editableLayout: Action<HTMLButtonElement, {id: number; quadrant: number}> = (
|
||||
node,
|
||||
{id, quadrant},
|
||||
) => {
|
||||
let component: InputEdit | undefined
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
appendTo: document.body,
|
||||
trigger: "click",
|
||||
placement: (["top", "right", "bottom", "left"] as const)[quadrant],
|
||||
onShow(instance) {
|
||||
component ??= new InputEdit({
|
||||
target: instance.popper.querySelector(".tippy-content")!,
|
||||
props: {id},
|
||||
})
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy()
|
||||
component = undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
font-family: "Material Symbols Rounded";
|
||||
font-size: 24px;
|
||||
font-feature-settings: "liga";
|
||||
font-variation-settings: "FILL" var(--icon-fill, 0), "wght" var(--icon-weigth, 400), "GRAD"
|
||||
var(--icon-grade, 0);
|
||||
font-variation-settings: "FILL" var(--icon-fill, 0), "wght" var(--icon-weigth, 400),
|
||||
"GRAD" var(--icon-grade, 0);
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
|
||||
28
src/lib/popup.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
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})
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy()
|
||||
target?.classList.remove("active")
|
||||
component = undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
37
src/lib/preferences.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {Action} from "svelte/action"
|
||||
import {persistentWritable} from "$lib/storage"
|
||||
|
||||
export interface UserPreferences {
|
||||
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: true,
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
node.addEventListener("input", update)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe()
|
||||
node.removeEventListener("input", update)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type {CharaLayout} from "$lib/serial/connection"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
layout: CharaLayout
|
||||
chords: Chord[]
|
||||
}
|
||||
23
src/lib/serial/TauriSerialDialog.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte"
|
||||
|
||||
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>
|
||||
{/each}
|
||||
|
||||
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
|
||||
<button
|
||||
on:click={() =>
|
||||
dispatch(
|
||||
"confirm",
|
||||
ports.find(it => it.getInfo().name === selected),
|
||||
)}>Ok</button
|
||||
>
|
||||
</dialog>
|
||||
@@ -1,11 +1,12 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {
|
||||
chordAsCommandCompatible,
|
||||
chordFromCommandCompatible,
|
||||
deserializeActions,
|
||||
parseChordActions,
|
||||
parsePhrase,
|
||||
serializeActions,
|
||||
stringifyChordActions,
|
||||
stringifyPhrase,
|
||||
} from "./chord"
|
||||
import type {Chord} from "./chord"
|
||||
|
||||
describe("chords", function () {
|
||||
describe("actions", function () {
|
||||
@@ -25,18 +26,23 @@ describe("chords", function () {
|
||||
}
|
||||
})
|
||||
|
||||
describe("commands", function () {
|
||||
it("should convert to a command", function () {
|
||||
expect(chordAsCommandCompatible({actions: [32, 51], phrase: [0x01, 0x68, 0x72, 0xd4, 0x65]})).toEqual(
|
||||
"000CC200000000000000000000000000 016872D465",
|
||||
)
|
||||
describe("phrase", function () {
|
||||
it("should stringify", function () {
|
||||
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual("206872D4651FFF")
|
||||
})
|
||||
|
||||
it("should parse a command", function () {
|
||||
expect(chordFromCommandCompatible("000CC200000000000000000000000000 016872D465")).toEqual({
|
||||
actions: [32, 51],
|
||||
phrase: [0x01, 0x68, 0x72, 0xd4, 0x65],
|
||||
})
|
||||
it("should parse", function () {
|
||||
expect(parsePhrase("206872D4651FFF")).toEqual([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])
|
||||
})
|
||||
})
|
||||
|
||||
describe("chord actions", function () {
|
||||
it("should stringify", function () {
|
||||
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000")
|
||||
})
|
||||
|
||||
it("should parse", function () {
|
||||
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import {compressActions, decompressActions} from "../serialization/actions"
|
||||
|
||||
export interface Chord {
|
||||
actions: number[]
|
||||
phrase: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a chord into a serial-command-compatible string
|
||||
*
|
||||
* @example "000CC200000000000000000000000000 7468726565"
|
||||
*/
|
||||
export function chordAsCommandCompatible(chord: Chord): string {
|
||||
return `${serializeActions(chord.actions).toString(16).padStart(32, "0")} ${chord.phrase
|
||||
.map(it => it.toString(16).padStart(2, "0"))
|
||||
.join("")}`.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a command response into a chord
|
||||
*
|
||||
* @see {chordAsCommandCompatible}
|
||||
*/
|
||||
export function chordFromCommandCompatible(command: string): Chord {
|
||||
const [actions, phrase] = command.split(" ")
|
||||
return {
|
||||
actions: deserializeActions(BigInt(`0x${actions}`)),
|
||||
phrase: Array.from({length: phrase.length / 2}).map((_, i) =>
|
||||
export function parsePhrase(phrase: string): number[] {
|
||||
return decompressActions(
|
||||
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"))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
export function parseChordActions(actions: string): number[] {
|
||||
return deserializeActions(BigInt(`0x${actions}`))
|
||||
}
|
||||
|
||||
export function stringifyChordActions(actions: number[]): string {
|
||||
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {writable} from "svelte/store"
|
||||
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"
|
||||
|
||||
export const serialPort = writable<CharaDevice>()
|
||||
export const serialPort = writable<CharaDevice | undefined>()
|
||||
|
||||
export interface SerialLogEntry {
|
||||
type: "input" | "output" | "system"
|
||||
@@ -12,23 +15,28 @@ export interface SerialLogEntry {
|
||||
|
||||
export const serialLog = writable<SerialLogEntry[]>([])
|
||||
|
||||
export const chords = writable<Chord[]>([])
|
||||
export const chords = persistentWritable<Chord[]>("chord-library", [], () => get(userPreferences).backup)
|
||||
|
||||
export type CharaLayout = [number[], number[], number[]]
|
||||
export const layout = persistentWritable<CharaLayout>(
|
||||
"layout",
|
||||
[[], [], []],
|
||||
() => get(userPreferences).backup,
|
||||
)
|
||||
|
||||
export const layout = writable<CharaLayout>([[], [], []])
|
||||
export const settings = writable({})
|
||||
|
||||
export const unsavedChanges = writable(0)
|
||||
|
||||
export const highlightActions: Writable<number[]> = writable([])
|
||||
|
||||
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
|
||||
|
||||
let device: CharaDevice // @hmr:keep
|
||||
|
||||
export async function initSerial() {
|
||||
syncStatus.set("downloading")
|
||||
device ??= new CharaDevice()
|
||||
export async function initSerial(manual = false) {
|
||||
const device = get(serialPort) ?? new CharaDevice()
|
||||
await device.init(manual)
|
||||
serialPort.set(device)
|
||||
|
||||
syncStatus.set("downloading")
|
||||
const parsedLayout: CharaLayout = [[], [], []]
|
||||
for (let layer = 1; layer <= 3; layer++) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
|
||||
@@ -1,82 +1,95 @@
|
||||
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
|
||||
import {serialLog} from "$lib/serial/connection"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import {chordFromCommandCompatible} from "$lib/serial/chord"
|
||||
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
|
||||
import {browser} from "$app/environment"
|
||||
|
||||
export const VENDOR_ID = 0x239a
|
||||
|
||||
export async function hasSerialPermission() {
|
||||
return navigator.serial.getPorts().then(it => it.length > 0)
|
||||
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 => it.getInfo().usbVendorId === VENDOR_ID))
|
||||
}
|
||||
|
||||
export async function canAutoConnect() {
|
||||
return getViablePorts().then(it => it.length === 1)
|
||||
}
|
||||
|
||||
export class CharaDevice {
|
||||
private readonly port: Promise<SerialPort>
|
||||
private readonly reader: Promise<ReadableStreamDefaultReader<string>>
|
||||
private port!: SerialPort
|
||||
private reader!: ReadableStreamDefaultReader<string>
|
||||
|
||||
private readonly abortController1 = new AbortController()
|
||||
private readonly abortController2 = new AbortController()
|
||||
|
||||
private streamClosed!: Promise<void>
|
||||
|
||||
private lock?: Promise<true>
|
||||
|
||||
version: Promise<string>
|
||||
deviceId: Promise<string>
|
||||
version!: [number, number, number]
|
||||
company!: "CHARACHORDER"
|
||||
device!: "ONE" | "LITE"
|
||||
chipset!: "M0" | "S2"
|
||||
|
||||
constructor(baudRate = 115200) {
|
||||
this.port = navigator.serial.getPorts().then(async ports => {
|
||||
const port =
|
||||
ports.find(it => it.getInfo().usbVendorId === VENDOR_ID) ??
|
||||
(await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]}))
|
||||
await port.open({baudRate})
|
||||
const info = port.getInfo()
|
||||
serialLog.update(it => {
|
||||
it.push({
|
||||
type: "system",
|
||||
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
|
||||
16,
|
||||
)}`,
|
||||
})
|
||||
return it
|
||||
constructor(private readonly baudRate = 115200) {}
|
||||
|
||||
async init(manual = false) {
|
||||
const ports = await getViablePorts()
|
||||
this.port =
|
||||
!manual && ports.length === 1
|
||||
? ports[0]
|
||||
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
|
||||
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(
|
||||
16,
|
||||
)}`,
|
||||
})
|
||||
return port
|
||||
return it
|
||||
})
|
||||
this.reader = this.port.then(async port => {
|
||||
const decoderStream = new TextDecoderStream()
|
||||
void port.readable!.pipeTo(decoderStream.writable, {signal: this.abortController1.signal})
|
||||
|
||||
return decoderStream
|
||||
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
|
||||
signal: this.abortController2.signal,
|
||||
})
|
||||
.getReader()
|
||||
const decoderStream = new TextDecoderStream()
|
||||
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
|
||||
signal: this.abortController1.signal,
|
||||
})
|
||||
this.lock = this.reader.then(() => {
|
||||
delete this.lock
|
||||
return true
|
||||
})
|
||||
this.version = this.send("VERSION")
|
||||
this.deviceId = this.send("ID")
|
||||
|
||||
this.reader = decoderStream
|
||||
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
|
||||
signal: this.abortController2.signal,
|
||||
})
|
||||
.getReader()
|
||||
|
||||
const [version] = await this.send("VERSION")
|
||||
this.version = version.split(".").map(Number) as [number, number, number]
|
||||
const [company, device, chipset] = await this.send("ID")
|
||||
this.company = company as "CHARACHORDER"
|
||||
this.device = device as "ONE" | "LITE"
|
||||
this.chipset = chipset as "M0" | "S2"
|
||||
}
|
||||
|
||||
private async internalRead() {
|
||||
return this.reader.then(async it => {
|
||||
const result: string = await it.read().then(({value}) => value!)
|
||||
serialLog.update(it => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result,
|
||||
})
|
||||
return it
|
||||
const {value} = await this.reader.read()
|
||||
serialLog.update(it => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: value!,
|
||||
})
|
||||
return result
|
||||
return it
|
||||
})
|
||||
return value!
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the device
|
||||
*/
|
||||
private async internalSend(...command: string[]) {
|
||||
const port = await this.port
|
||||
const writer = port.writable!.getWriter()
|
||||
const writer = this.port.writable!.getWriter()
|
||||
try {
|
||||
serialLog.update(it => {
|
||||
it.push({
|
||||
@@ -91,6 +104,20 @@ export class CharaDevice {
|
||||
}
|
||||
}
|
||||
|
||||
async forget() {
|
||||
await this.disconnect()
|
||||
await this.port.forget()
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.reader.cancel()
|
||||
await this.streamClosed.catch(() => {
|
||||
/** noop */
|
||||
})
|
||||
this.reader.releaseLock()
|
||||
await this.port.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write to serial port
|
||||
*/
|
||||
@@ -122,21 +149,128 @@ export class CharaDevice {
|
||||
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} `), ""))
|
||||
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" "))
|
||||
})
|
||||
}
|
||||
|
||||
async getChordCount(): Promise<number> {
|
||||
return Number.parseInt(await this.send("CML C0"))
|
||||
const [count] = await this.send("CML C0")
|
||||
return Number.parseInt(count)
|
||||
}
|
||||
|
||||
async getChord(index: number): Promise<Chord> {
|
||||
return chordFromCommandCompatible(await this.send(`CML C1 ${index}`))
|
||||
/**
|
||||
* Retrieves a chord by index
|
||||
*/
|
||||
async getChord(index: number | number[]): Promise<Chord> {
|
||||
const [actions, phrase] = await this.send(`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 === "0" ? undefined : parsePhrase(phrase)
|
||||
}
|
||||
|
||||
async setChord(chord: Chord) {
|
||||
const [status] = await this.send(
|
||||
"CML",
|
||||
"C3",
|
||||
stringifyChordActions(chord.actions),
|
||||
stringifyPhrase(chord.phrase),
|
||||
)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
}
|
||||
|
||||
async deleteChord(chord: Chord) {
|
||||
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
|
||||
if (status.at(-1) !== "0") throw new Error(`Failed with status ${status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an action to the layout
|
||||
* @param layer the layer (usually 1-3)
|
||||
* @param id id of the key, refer to the individual device for where each key is
|
||||
* @param action the assigned action id
|
||||
*/
|
||||
async setLayoutKey(layer: number, id: number, action: number) {
|
||||
const [status] = await this.send(`VAR B3 A${layer} ${id} ${action}`)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the assigned action from the layout
|
||||
* @param layer the layer (usually 1-3)
|
||||
* @param id id of the key, refer to the individual device for where each key is
|
||||
* @returns the assigned action id
|
||||
*/
|
||||
async getLayoutKey(layer: number, id: number) {
|
||||
const layout = await this.send(`VAR B3 A${layer} ${id}`)
|
||||
const [position] = layout.split(" ").map(Number)
|
||||
return position
|
||||
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
return Number(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently stores settings and layout to the device.
|
||||
*
|
||||
* CAUTION: Device may degrade prematurely above 10,000-25,000 commits.
|
||||
*
|
||||
* **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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a setting on the device.
|
||||
*
|
||||
* Settings are applied until the next reboot or loss of power.
|
||||
* To permanently store the settings, you *must* call commit.
|
||||
*/
|
||||
async setSetting(id: number, value: number) {
|
||||
const [status] = await this.send(`VAR B2 ${id} ${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}`)
|
||||
if (status !== "0") throw new Error(`Setting "${id}" doesn't exist (Status code ${status})`)
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboots the device
|
||||
*/
|
||||
async reboot() {
|
||||
await this.send("RST")
|
||||
await this.disconnect()
|
||||
// TODO: reconnect
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboots the device to the bootloader
|
||||
*/
|
||||
async bootloader() {
|
||||
await this.send("RST BOOTLOADER")
|
||||
await this.disconnect()
|
||||
// TODO: more...
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current number of bytes available in SRAM.
|
||||
*
|
||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||
*/
|
||||
async getRamBytesAvailable(): Promise<number> {
|
||||
return Number(await this.send("RAM"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Compress JSON.stringify with gzip
|
||||
*/
|
||||
export async function stringifyCompressed(chords: any): Promise<Blob> {
|
||||
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()
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export async function stringifyCompressed(chords: any): Promise<Blob> {
|
||||
* Decompress JSON.parse with gzip
|
||||
*/
|
||||
export async function parseCompressed<T>(blob: Blob): Promise<T> {
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("gzip"))
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"))
|
||||
return await new Response(stream).json()
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export async function getSharableUrl(name: string, data: any, baseHref = window.
|
||||
return new Promise(async resolve => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = function () {
|
||||
const base64String = (reader.result as string)
|
||||
.replace(/^data:application\/octet-stream;base64,/, "")
|
||||
.replace(/==$/, "")
|
||||
const base64String = (reader.result as string).replace(/^data:application\/octet-stream;base64,/, "")
|
||||
const url = new URL(baseHref)
|
||||
url.searchParams.set(name, base64String)
|
||||
resolve(url)
|
||||
@@ -31,3 +29,15 @@ export async function getSharableUrl(name: string, data: any, baseHref = window.
|
||||
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
|
||||
|
||||
return await fetch(`data:application/octet-stream;base64,${searchParams.get(name)}`)
|
||||
.then(it => it.blob())
|
||||
.then(it => parseCompressed(it))
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import {chords, layout} from "$lib/serial/connection"
|
||||
|
||||
const PROFILE_KEY = "profiles"
|
||||
const CHORD_LIBRARY_STORAGE_KEY = "chord-library"
|
||||
const LAYOUT_STORAGE_KEY = "layouts"
|
||||
|
||||
export function initLocalStorage() {
|
||||
const storedLayout = localStorage.getItem(LAYOUT_STORAGE_KEY)
|
||||
if (storedLayout) {
|
||||
layout.set(JSON.parse(storedLayout))
|
||||
}
|
||||
const storedChords = localStorage.getItem(CHORD_LIBRARY_STORAGE_KEY)
|
||||
if (storedChords) {
|
||||
chords.set(JSON.parse(storedChords))
|
||||
}
|
||||
|
||||
layout.subscribe(layout => {
|
||||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout))
|
||||
})
|
||||
chords.subscribe(chords => {
|
||||
localStorage.setItem(CHORD_LIBRARY_STORAGE_KEY, JSON.stringify(chords))
|
||||
})
|
||||
}
|
||||
8
src/lib/serial/tauri-serial-extension.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <references types="@types/w3c-web-serial" />
|
||||
|
||||
interface SerialPortInfo {
|
||||
name?: string
|
||||
serialNumber?: string
|
||||
manufacturer?: string
|
||||
product?: string
|
||||
}
|
||||
65
src/lib/serial/tauri-serial.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
},
|
||||
async open({baudRate}: SerialOptions) {
|
||||
await invoke("plugin:serial|open", {path: info.name, baudRate})
|
||||
},
|
||||
async close() {
|
||||
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))
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream({
|
||||
async write(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 =>
|
||||
ports.map(NativeSerialPort),
|
||||
) as Promise<SerialPort[]>
|
||||
},
|
||||
async requestPort(options?: SerialPortRequestOptions): Promise<SerialPort> {
|
||||
const ports = await navigator.serial.getPorts().then(ports =>
|
||||
options?.filters !== undefined
|
||||
? ports.filter(port =>
|
||||
options.filters!.some(({usbVendorId, usbProductId}) => {
|
||||
const info = port.getInfo()
|
||||
return (
|
||||
(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
|
||||
},
|
||||
}
|
||||
12
src/lib/serialization/actions.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
|
||||
describe("layout", function () {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
33
src/lib/serialization/actions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Compresses an action list into a Uint8Array of variable-length 8/13-bit integers.
|
||||
*
|
||||
* Action codes <32 are invalid.
|
||||
*/
|
||||
export function compressActions(actions: number[]): Uint8Array {
|
||||
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 & 0xff
|
||||
}
|
||||
return buffer.slice(0, i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses actions
|
||||
*
|
||||
* @see {compressActions}
|
||||
*/
|
||||
export function decompressActions(raw: Uint8Array): number[] {
|
||||
const actions: number[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
let action = raw[i]
|
||||
if (action < 32) {
|
||||
action = (action << 8) | raw[++i]
|
||||
}
|
||||
actions.push(action)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
12
src/lib/serialization/base64.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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])
|
||||
|
||||
it("should convert back-forth", async function () {
|
||||
expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual(
|
||||
data.buffer,
|
||||
)
|
||||
})
|
||||
})
|
||||
31
src/lib/serialization/base64.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Encodes a gzipped binary blob to a base64 string.
|
||||
*
|
||||
* Note that the string is url-compatible base64,
|
||||
* meaning some chars are swapped for compatibility
|
||||
*/
|
||||
export async function toBase64(blob: Blob): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = function () {
|
||||
resolve(
|
||||
`${(reader.result as string)
|
||||
.replace(/^data:application\/octet-stream;base64,/, "")
|
||||
.replaceAll("+", ".")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "-")}-`,
|
||||
)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export async function fromBase64(base64: string): Promise<Blob> {
|
||||
return fetch(
|
||||
`data:application/octet-stream;base64,${base64
|
||||
.replace(/-$/, "")
|
||||
.replaceAll(".", "+")
|
||||
.replaceAll("_", "/")
|
||||
.replaceAll("-", "=")}`,
|
||||
).then(it => it.blob())
|
||||
}
|
||||
21
src/lib/serialization/layout.sample.json
Normal file
@@ -0,0 +1,21 @@
|
||||
[
|
||||
[
|
||||
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, 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
|
||||
]
|
||||
]
|
||||
28
src/lib/serialization/layout.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
import {fromBase64, toBase64} from "$lib/serialization/base64"
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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)]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
35
src/lib/setting.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {Action} from "svelte/action"
|
||||
import {serialPort} from "$lib/serial/connection"
|
||||
|
||||
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
|
||||
node: HTMLInputElement,
|
||||
{id, inverse, scale},
|
||||
) {
|
||||
node.setAttribute("disabled", "")
|
||||
|
||||
const unsubscribe = serialPort.subscribe(async port => {
|
||||
if (port) {
|
||||
const type = node.getAttribute("type") as "number" | "checkbox"
|
||||
if (type === "number") {
|
||||
const value = Number(await port.getSetting(id).then(it => it.toString()))
|
||||
node.value = (
|
||||
inverse !== undefined ? inverse / value : scale !== undefined ? scale * value : value
|
||||
).toString()
|
||||
} else {
|
||||
node.checked = await port.getSetting(id).then(it => it !== 0)
|
||||
}
|
||||
node.removeAttribute("disabled")
|
||||
} else {
|
||||
node.setAttribute("disabled", "")
|
||||
}
|
||||
})
|
||||
function listener() {}
|
||||
node.addEventListener("input", listener)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("input", listener)
|
||||
unsubscribe()
|
||||
},
|
||||
}
|
||||
}
|
||||
22
src/lib/share.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {Action} from "svelte/action"
|
||||
import {readonly, writable} from "svelte/store"
|
||||
|
||||
const setCanShare = writable(false)
|
||||
export const canShare = readonly(setCanShare)
|
||||
|
||||
let shareCallback: ((event: Event) => void) | undefined
|
||||
export function triggerShare(event: Event) {
|
||||
shareCallback?.(event)
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
17
src/lib/storage.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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> {
|
||||
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))
|
||||
})
|
||||
|
||||
return store
|
||||
} else {
|
||||
return writable(value)
|
||||
}
|
||||
}
|
||||
35
src/lib/style/tippy.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
$padding: 16px;
|
||||
|
||||
.tippy-box[data-theme~="surface-variant"] {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
filter: drop-shadow(0 0 12px #000a);
|
||||
border-radius: calc(24px + $padding);
|
||||
|
||||
.tippy-content {
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-block-start: 8px;
|
||||
margin-block-end: calc(8px + $padding);
|
||||
}
|
||||
|
||||
@each $placement in top, bottom, right, left {
|
||||
&[data-placement^="#{$placement}"] > .tippy-arrow::before {
|
||||
border-#{$placement}-color: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="search-completion"] {
|
||||
overflow: hidden;
|
||||
filter: none;
|
||||
border-radius: 0 0 16px 16px;
|
||||
|
||||
.tippy-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
65
src/lib/style/toggle.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
$padding: 3px;
|
||||
$border: 2px;
|
||||
$height: 1.5em;
|
||||
|
||||
label:has(input[type="checkbox"]) {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
gap: $padding;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 12px;
|
||||
|
||||
input[type="checkbox"] {
|
||||
$width: calc($height * (5 / 3));
|
||||
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
|
||||
$radius: calc($diameter / 2);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
width: $width;
|
||||
height: $height;
|
||||
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
|
||||
border-radius: calc($height / 2);
|
||||
outline: $border solid currentcolor;
|
||||
outline-offset: calc(-1 * $border);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: calc($padding + $border);
|
||||
left: calc($padding + $border);
|
||||
|
||||
display: block;
|
||||
|
||||
width: $diameter;
|
||||
height: $diameter;
|
||||
|
||||
border-radius: calc($radius);
|
||||
outline-color: inherit;
|
||||
outline-style: solid;
|
||||
outline-width: $radius;
|
||||
outline-offset: calc(-1 * $radius);
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
translate: calc($width - 2 * $diameter - $padding / 2) 0;
|
||||
outline-width: calc($width - ($height - $border) + $padding);
|
||||
outline-offset: calc($padding / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/lib/tooltip.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
instance.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1>
|
||||
<pre>{$page.error.message}</pre>
|
||||
<pre>{$page.error?.message}</pre>
|
||||
|
||||
@@ -2,47 +2,62 @@
|
||||
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/toggle.scss"
|
||||
import {onMount} from "svelte"
|
||||
import {applyTheme, argbFromHex, themeFromSourceColor} from "@material/material-color-utilities"
|
||||
import Navigation from "$lib/components/Navigation.svelte"
|
||||
import {hasSerialPermission} from "$lib/serial/device"
|
||||
import Navigation from "./Navigation.svelte"
|
||||
import {canAutoConnect} from "$lib/serial/device"
|
||||
import {initSerial} from "$lib/serial/connection"
|
||||
// noinspection TypeScriptCheckImport
|
||||
import {pwaInfo} from "virtual:pwa-info"
|
||||
import type {LayoutServerData} from "./$types"
|
||||
import type {RegisterSWOptions} from "vite-plugin-pwa/types"
|
||||
import {initLocalStorage} from "$lib/serial/storage"
|
||||
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 {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"
|
||||
|
||||
const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale()
|
||||
loadLocale(locale)
|
||||
setLocale(locale)
|
||||
|
||||
if (browser) {
|
||||
tippy.setDefaultProps({
|
||||
animation: "shift-away",
|
||||
theme: "surface-variant",
|
||||
allowHTML: true,
|
||||
duration: 250,
|
||||
maxWidth: "none",
|
||||
arrow: true,
|
||||
})
|
||||
}
|
||||
|
||||
export let data: LayoutServerData
|
||||
|
||||
onMount(async () => {
|
||||
const theme = themeFromSourceColor(argbFromHex("#6D81C7"), [
|
||||
{name: "success", value: argbFromHex("#00ff00"), blend: true},
|
||||
])
|
||||
const dark = true // window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
applyTheme(theme, {target: document.body, dark})
|
||||
initLocalStorage()
|
||||
|
||||
if (pwaInfo) {
|
||||
// noinspection TypeScriptCheckImport
|
||||
const {registerSW} = await import("virtual:pwa-register")
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegisterError(error) {
|
||||
console.log("ServiceWorker Registration Error", error)
|
||||
},
|
||||
} satisfies RegisterSWOptions)
|
||||
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")
|
||||
await initPwa()
|
||||
}
|
||||
|
||||
if (await hasSerialPermission()) await initSerial()
|
||||
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) await initSerial()
|
||||
})
|
||||
|
||||
$: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : ""
|
||||
let webManifestLink = ""
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html webManifestLink}
|
||||
<title>dot i/o</title>
|
||||
<title>amaCC1ng</title>
|
||||
<meta name="description" content="Tool for CharaChorder devices" />
|
||||
<meta name="theme-color" content={data.themeColor} />
|
||||
</svelte:head>
|
||||
@@ -53,6 +68,10 @@
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
||||
<BrowserWarning />
|
||||
{/if}
|
||||
|
||||
<style lang="scss" global>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -63,13 +82,23 @@
|
||||
color: var(--md-sys-color-tertiary);
|
||||
}
|
||||
|
||||
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%);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
128
src/routes/BackupPopup.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import {parseCompressed, stringifyCompressed} from "$lib/serial/serialization"
|
||||
import {chords, layout} from "$lib/serial/connection"
|
||||
import {preference} from "$lib/preferences"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import type {CharaLayout} from "$lib/serialization/layout"
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
|
||||
interface Backup {
|
||||
isCharaBackup: string
|
||||
chords: Chord[]
|
||||
layout: CharaLayout
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
const downloadUrl = URL.createObjectURL(
|
||||
await stringifyCompressed({
|
||||
isCharaBackup: "v1.0",
|
||||
chords: $chords,
|
||||
layout: $layout,
|
||||
}),
|
||||
)
|
||||
const element = document.createElement("a")
|
||||
element.setAttribute("download", "chords.chb")
|
||||
element.href = downloadUrl
|
||||
element.setAttribute("target", "_blank")
|
||||
element.click()
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}
|
||||
|
||||
async function restoreBackup(event: Event) {
|
||||
const input = (event.target as HTMLInputElement).files![0]
|
||||
if (!input) return
|
||||
const backup = await parseCompressed<Backup>(input)
|
||||
if (backup.isCharaBackup !== "v1.0") throw new Error("Invalid Backup")
|
||||
if (backup.chords) {
|
||||
$chords = backup.chords
|
||||
}
|
||||
if (backup.layout) {
|
||||
$layout = backup.layout
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2><label><input type="checkbox" use:preference={"backup"} />{$LL.backup.TITLE()}</label></h2>
|
||||
<p class="disclaimer">
|
||||
<i>{$LL.backup.DISCLAIMER()}</i>
|
||||
</p>
|
||||
<div class="save">
|
||||
<button class="primary" on:click={downloadBackup}
|
||||
><span class="icon">save</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input on:input={restoreBackup} type="file" /><span class="icon">settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
margin-block-end: 0;
|
||||
|
||||
> label {
|
||||
gap: 10px;
|
||||
font-size: 24px;
|
||||
|
||||
> input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
max-width: 16cm;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.save {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: max-content;
|
||||
height: 48px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 32px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
</style>
|
||||
81
src/routes/BrowserWarning.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
</script>
|
||||
|
||||
<dialog open>
|
||||
<h1>{$LL.browserWarning.TITLE()}</h1>
|
||||
<p>
|
||||
{$LL.browserWarning.INFO_SERIAL_PREFIX()}<a
|
||||
class="normal"
|
||||
target="_blank"
|
||||
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility"
|
||||
>{$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
|
||||
>{$LL.browserWarning.INFO_BROWSER_SUFFIX()}
|
||||
</p>
|
||||
<div>
|
||||
<a href="https://github.com/Theaninova/dotio/releases" target="_blank"
|
||||
>{$LL.browserWarning.DOWNLOAD_APP()}</a
|
||||
>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
color: var(--md-sys-color-on-error);
|
||||
|
||||
background: var(--md-sys-color-error);
|
||||
border: none;
|
||||
|
||||
> * {
|
||||
max-width: 20cm;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--md-sys-color-on-error);
|
||||
}
|
||||
|
||||
div > a {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
display: inline-block;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
background: var(--md-sys-color-on-error);
|
||||
}
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
opacity: 0.8;
|
||||
background: black;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
197
src/routes/ConnectionPopup.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<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"
|
||||
|
||||
let terminal = false
|
||||
let powerDialog = false
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="row">
|
||||
<h2>{$LL.deviceManager.TITLE()}</h2>
|
||||
<label>{$LL.deviceManager.AUTO_CONNECT()}<input type="checkbox" use:preference={"autoConnect"} /></label>
|
||||
</div>
|
||||
|
||||
{#if $serialPort}
|
||||
<p transition:slide>
|
||||
{$serialPort.company}
|
||||
{$serialPort.device}
|
||||
{$serialPort.chipset}
|
||||
<br />
|
||||
Version {$serialPort.version.map(it => it.toString()).join(".")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if browser}
|
||||
<div class="row">
|
||||
{#if $serialPort}
|
||||
<button
|
||||
class="secondary"
|
||||
on:click={() => {
|
||||
$serialPort?.forget()
|
||||
$serialPort = undefined
|
||||
}}><span class="icon">usb_off</span>{$LL.deviceManager.DISCONNECT()}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="error" on:click={() => initSerial(true)}
|
||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||
>
|
||||
{/if}
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<a
|
||||
href="/terminal"
|
||||
title={$LL.deviceManager.TERMINAL()}
|
||||
class="icon"
|
||||
class:disabled={$serialPort === undefined}
|
||||
on:click={() => (terminal = !terminal)}>terminal</a
|
||||
>
|
||||
<button
|
||||
class="icon"
|
||||
title={$LL.deviceManager.bootMenu.TITLE()}
|
||||
disabled={$serialPort === undefined}
|
||||
on:click={() => (powerDialog = !powerDialog)}>settings_power</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if powerDialog}
|
||||
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
|
||||
<dialog open transition:slide={{duration: 250}}>
|
||||
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
|
||||
<button
|
||||
on:click={() => {
|
||||
$serialPort?.reboot()
|
||||
$serialPort = undefined
|
||||
}}><span class="icon">restart_alt</span>{$LL.deviceManager.bootMenu.REBOOT()}</button
|
||||
>
|
||||
<button
|
||||
on:click={() => {
|
||||
$serialPort?.bootloader()
|
||||
$serialPort = undefined
|
||||
}}><span class="icon">rule_settings</span>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
|
||||
>
|
||||
</dialog>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
margin-block: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block: 8px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
|
||||
background: #0005;
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-block-start: 16px;
|
||||
padding: 0;
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border: none;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
dialog > * {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
dialog > :first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-block: 8px;
|
||||
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 48px;
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
|
||||
font-size: 1rem;
|
||||
color: var(--md-sys-color-on-background);
|
||||
text-decoration: none;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 32px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&.icon {
|
||||
aspect-ratio: 1;
|
||||
padding-inline-end: 8px;
|
||||
font-size: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
a.disabled,
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,16 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import {serialPort, syncStatus} from "$lib/serial/connection"
|
||||
import {browser} from "$app/environment"
|
||||
import {page} from "$app/stores"
|
||||
import {slide} from "svelte/transition"
|
||||
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 {canAutoConnect} from "$lib/serial/device"
|
||||
import {browser} from "$app/environment"
|
||||
import {userPreferences} from "$lib/preferences"
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
import Profile from "./Profile.svelte"
|
||||
|
||||
const training = [
|
||||
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
|
||||
@@ -12,10 +20,16 @@
|
||||
{slug: "top-wpm", title: "tWPM - Top Words Per Minute", icon: "speed"},
|
||||
{slug: "cm", title: "CM - Concepts Mastered", icon: "cognition"},
|
||||
]
|
||||
|
||||
$: if (browser && !canAutoConnect()) {
|
||||
connectButton?.click()
|
||||
}
|
||||
|
||||
let connectButton: HTMLButtonElement
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href="/" class="title">dot i/o</a>
|
||||
<a href="/" class="title">{$LL.TITLE()}</a>
|
||||
|
||||
<div class="steps">
|
||||
{#each training as { slug, title, icon }}
|
||||
@@ -29,41 +43,38 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||
<PwaStatus />
|
||||
{/await}
|
||||
{#if browser && !("serial" in navigator)}
|
||||
<abbr
|
||||
title="Your browser does not support serial connections. Try using Chrome instead."
|
||||
class="icon error"
|
||||
>
|
||||
warning
|
||||
</abbr>
|
||||
{#if $canShare}
|
||||
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
|
||||
<div transition:slide class="separator" />
|
||||
{/if}
|
||||
<a
|
||||
title="Backup & Restore"
|
||||
href="/backup/"
|
||||
class="icon {$syncStatus}"
|
||||
class:active={$page.url.pathname.startsWith("/backup/")}
|
||||
>
|
||||
{#if $syncStatus === "downloading"}
|
||||
backup
|
||||
{:else if $syncStatus === "uploading"}
|
||||
cloud_download
|
||||
{:else}
|
||||
cloud_done
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="/config/"
|
||||
title="Device Manager"
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined}
|
||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||
<PwaStatus />
|
||||
{/await}
|
||||
{/if}
|
||||
{#if $serialPort}
|
||||
<button title={$LL.backup.TITLE()} use:popup={BackupPopup} class="icon {$syncStatus}">
|
||||
{#if $syncStatus === "downloading"}
|
||||
backup
|
||||
{:else if $syncStatus === "uploading"}
|
||||
cloud_download
|
||||
{:else if $userPreferences.backup}
|
||||
cloud_done
|
||||
{:else}
|
||||
cloud_off
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={connectButton}
|
||||
title="Devices"
|
||||
use:popup={ConnectionPopup}
|
||||
class="icon connect"
|
||||
class:active={$page.url.pathname.startsWith("/config/")}
|
||||
class:error={$serialPort === undefined}
|
||||
>
|
||||
cable
|
||||
</a>
|
||||
<a href="/" title="Statistics" class="icon account">person</a>
|
||||
</button>
|
||||
<button title={$LL.profile.TITLE()} use:popup={Profile} class="icon account">person</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -119,6 +130,12 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -143,6 +160,10 @@
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding: 4px;
|
||||
|
||||
@@ -168,6 +189,9 @@
|
||||
}
|
||||
|
||||
.steps {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
display: flex;
|
||||
|
||||
> a.icon {
|
||||
116
src/routes/Profile.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import LL, {setLocale} from "../i18n/i18n-svelte"
|
||||
import {theme} from "$lib/preferences"
|
||||
import {tick} from "svelte"
|
||||
import {detectLocale, locales} from "../i18n/i18n-util"
|
||||
import {loadLocaleAsync} from "../i18n/i18n-util.async"
|
||||
import type {Locales} from "../i18n/i18n-types"
|
||||
|
||||
let locale = (localStorage.getItem("locale") as Locales) || detectLocale()
|
||||
$: (async () => {
|
||||
localStorage.setItem("locale", locale)
|
||||
await loadLocaleAsync(locale)
|
||||
setLocale(locale)
|
||||
})()
|
||||
|
||||
function switchTheme() {
|
||||
const mode = $theme.mode === "light" ? "dark" : "light"
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(async () => {
|
||||
$theme.mode = mode
|
||||
await tick()
|
||||
})
|
||||
} else {
|
||||
$theme.mode = mode
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>{$LL.profile.TITLE()}</h2>
|
||||
<fieldset>
|
||||
<legend>
|
||||
<span class="icon">format_paint</span>
|
||||
{$LL.profile.theme.TITLE()}
|
||||
</legend>
|
||||
|
||||
<input title={$LL.profile.theme.COLOR_SCHEME()} type="color" bind:value={$theme.color} />
|
||||
<button
|
||||
title={$theme.mode === "light" ? $LL.profile.theme.LIGHT_MODE() : $LL.profile.theme.DARK_MODE()}
|
||||
class="icon"
|
||||
on:click={switchTheme}
|
||||
>
|
||||
{#if $theme.mode === "light"}
|
||||
light_mode
|
||||
{:else if $theme.mode === "dark"}
|
||||
dark_mode
|
||||
{:else}
|
||||
TODO
|
||||
{/if}
|
||||
</button>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>
|
||||
<span class="icon">translate</span>
|
||||
{$LL.profile.LANGUAGE()}
|
||||
</legend>
|
||||
{#each locales as code}
|
||||
<label>{code}<input bind:group={locale} type="radio" value={code} name="language" /></label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
legend {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="color"] {
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
inline-size: 24px;
|
||||
block-size: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script>
|
||||
import {getSharableUrl, stringifyCompressed} from "$lib/serial/serialization"
|
||||
import {chords} from "$lib/serial/connection"
|
||||
|
||||
async function downloadBackup() {
|
||||
const downloadUrl = URL.createObjectURL(await stringifyCompressed($chords))
|
||||
const element = document.createElement("a")
|
||||
element.setAttribute("download", "chords.chl")
|
||||
element.href = downloadUrl
|
||||
element.setAttribute("target", "_blank")
|
||||
element.click()
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}
|
||||
|
||||
async function createShareUrl() {
|
||||
console.log(await getSharableUrl("chords", $chords))
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Backup & Restore</h1>
|
||||
|
||||
<button on:click={downloadBackup}><span class="icon">save</span> Backup</button>
|
||||
<button><span class="icon">settings_backup_restore</span> Restore</button>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
import {page} from "$app/stores"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
|
||||
const paths = [
|
||||
{href: "/config/chords/", title: "Chords", icon: "piano"},
|
||||
{href: "/config/layout/", title: "Layout", icon: "keyboard"},
|
||||
{href: "/config/settings/", title: "Settings", icon: "settings"},
|
||||
$: 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"},
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,44 +1,55 @@
|
||||
<script lang="ts">
|
||||
import {chords} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import FlexSearch from "flexsearch"
|
||||
import type {Index} from "flexsearch"
|
||||
import Index from "flexsearch"
|
||||
import {tick} from "svelte"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import LL from "../../../i18n/i18n-svelte"
|
||||
import {actionAutocomplete} from "$lib/action-autocomplete"
|
||||
|
||||
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
|
||||
|
||||
function buildIndex(chords: Chord[]): Index {
|
||||
const index = new FlexSearch({tokenize: "full"})
|
||||
const index = new Index({tokenize: "full"})
|
||||
chords.forEach((chord, i) => {
|
||||
index.add(
|
||||
i,
|
||||
chord.phrase.map(it => KEYMAP_CODES[it].id),
|
||||
)
|
||||
index.add(i, chord.phrase.map(it => KEYMAP_CODES[it].id).join(""))
|
||||
})
|
||||
return index
|
||||
}
|
||||
|
||||
let searchFilter: number[] | undefined
|
||||
|
||||
function search(event) {
|
||||
function search(event: Event) {
|
||||
document.startViewTransition(async () => {
|
||||
const query = event.target.value
|
||||
const query = (event.target as HTMLInputElement).value
|
||||
searchFilter = query && searchIndex ? searchIndex.search(query) : undefined
|
||||
await tick()
|
||||
})
|
||||
}
|
||||
|
||||
$: items = searchFilter?.map(it => [$chords[it], it]) ?? $chords.map((it, i) => [it, i])
|
||||
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chord Manager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
|
||||
use:actionAutocomplete
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
{#if searchIndex}
|
||||
<input on:input={search} type="search" placeholder="Search {$chords.length} chords" />
|
||||
{/if}
|
||||
<input
|
||||
on:input={search}
|
||||
type="search"
|
||||
|
||||
/>
|
||||
{/if}-->
|
||||
|
||||
<section>
|
||||
<table>
|
||||
@@ -66,18 +77,17 @@
|
||||
|
||||
<style lang="scss">
|
||||
input[type="search"] {
|
||||
width: 300px;
|
||||
width: 512px;
|
||||
margin-block-start: 16px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 32px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-size: 16px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%);
|
||||
filter: brightness(80%);
|
||||
border: none;
|
||||
background: none;
|
||||
border: 0 solid var(--md-sys-color-surface-variant);
|
||||
border-bottom-width: 1px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
@@ -87,7 +97,7 @@
|
||||
}
|
||||
|
||||
&:focus {
|
||||
filter: brightness(90%);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
<script>
|
||||
import LayoutCC1 from "$lib/components/LayoutCC1.svelte"
|
||||
<script lang="ts">
|
||||
import {share} from "$lib/share"
|
||||
import {layout} from "$lib/serial/connection"
|
||||
import tippy from "tippy.js"
|
||||
import {onMount} from "svelte"
|
||||
import {layoutAsUrlComponent, layoutFromUrlComponent} from "$lib/serialization/layout"
|
||||
import Layout from "$lib/components/layout/Layout.svelte"
|
||||
|
||||
onMount(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has("layout")) {
|
||||
$layout = await layoutFromUrlComponent(url.searchParams.get("layout")!)
|
||||
}
|
||||
})
|
||||
|
||||
async function shareLayout(event: Event) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("layout", await layoutAsUrlComponent($layout))
|
||||
await navigator.clipboard.writeText(url.toString())
|
||||
tippy(event.target as HTMLElement, {
|
||||
content: "Share url copied!",
|
||||
delay: [0, 1000000],
|
||||
onHidden(instance) {
|
||||
instance.destroy()
|
||||
},
|
||||
}).show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window use:share={shareLayout} />
|
||||
|
||||
<section>
|
||||
<LayoutCC1 />
|
||||
<Layout />
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1 +1,228 @@
|
||||
<a class="icon" href="/config/settings/terminal/">terminal</a>
|
||||
<script>
|
||||
import {serialPort} from "$lib/serial/connection"
|
||||
import {setting} from "$lib/setting"
|
||||
</script>
|
||||
|
||||
{#if $serialPort}
|
||||
<form>
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" use:setting={{id: 41}} />Spurring</label></legend>
|
||||
<p>
|
||||
"Chording only" mode which tells your device to output chords on a press rather than a press &
|
||||
release. It also enables you to jump from one chord to another without releasing everything and can be
|
||||
activated in GTM or by chording both mirror keys. It can provide significant speed gains with
|
||||
chording, but also takes away the flexibility of character entry.
|
||||
</p>
|
||||
<p>Spurring also helps new users learn how to chord by eliminating the need to focus on timing.</p>
|
||||
<p>Spurring is toggled by chording both of the 'mirror' keys together.</p>
|
||||
<label
|
||||
>Character Counter Timeout<span class="unit"
|
||||
><input type="number" step="0.001" min="0" max="240" use:setting={{id: 43, scale: 0.001}} />s</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" use:setting={{id: 51}} />Arpeggiates</label></legend>
|
||||
<p>
|
||||
A quick, single key press and release used to indicate a suffix, prefix, or modifier to be associated
|
||||
with a chord.
|
||||
</p>
|
||||
<label
|
||||
>Tolerance<span class="unit"><input type="number" step="1" use:setting={{id: 54}} />ms</span></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" use:setting={{id: 12}} />Character Entry</label></legend>
|
||||
{#if $serialPort.device === "LITE"}
|
||||
<label>Swap Keymap 0 and 1<input type="checkbox" use:setting={{id: 13}} /></label>
|
||||
{/if}
|
||||
<label
|
||||
>Key Scan Rate<span class="unit"><input type="number" use:setting={{id: 14, inverse: 1000}} />Hz</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Key Debounce Press<span class="unit"><input type="number" use:setting={{id: 15}} />ms</span></label
|
||||
>
|
||||
<label
|
||||
>Key Debounce Release<span class="unit"><input type="number" use:setting={{id: 16}} />ms</span></label
|
||||
>
|
||||
<label
|
||||
>Output Character Delay<span class="unit"><input type="number" use:setting={{id: 17}} />µs</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" use:setting={{id: 21}} />Mouse</label></legend>
|
||||
<label
|
||||
>Mouse Speed<input type="number" use:setting={{id: 22}} /><input
|
||||
type="number"
|
||||
use:setting={{id: 23}}
|
||||
/></label
|
||||
>
|
||||
<label>Scroll Speed<input type="number" use:setting={{id: 25}} /></label>
|
||||
<label title="Bounces mouse by 1px every 60s if enabled"
|
||||
>Active Mouse<input type="checkbox" use:setting={{id: 24}} /></label
|
||||
>
|
||||
<label
|
||||
>Poll Rate<span class="unit"><input type="number" use:setting={{id: 26, inverse: 1000}} />Hz</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" use:setting={{id: 31}} />Chording</label></legend>
|
||||
<label
|
||||
>Character Timeout <span class="unit"
|
||||
><input type="number" min="0" max="25.5" step="0.1" use:setting={{id: 33, scale: 0.001}} />s</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Detection Tolerance<span class="unit"
|
||||
><input type="number" min="1" max="50" step="1" use:setting={{id: 34}} />ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Release Tolerance<span class="unit"
|
||||
><input type="number" min="1" max="50" step="1" use:setting={{id: 35}} />ms</span
|
||||
></label
|
||||
>
|
||||
<label>Compound Chording<input type="checkbox" use:setting={{id: 61}} /></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><label>Device</label></legend>
|
||||
<label>Boot message<input type="checkbox" use:setting={{id: 93}} /></label>
|
||||
<label>Realtime Feedback<input type="checkbox" use:setting={{id: 92}} /></label>
|
||||
<label>
|
||||
Operating System
|
||||
<select>
|
||||
<option value="0">Windows</option>
|
||||
<option value="1">MacOS</option>
|
||||
<option value="2">Linux</option>
|
||||
<option value="3">iOS</option>
|
||||
<option value="4">Android</option>
|
||||
<option value="255">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{#if $serialPort.device === "LITE"}
|
||||
<!-- TODO -->
|
||||
<fieldset>
|
||||
<legend><label><input type="checkbox" />RGB</label></legend>
|
||||
<label>Brightness<input type="range" min="0" max="50" step="1" /></label>
|
||||
<label>Color</label>
|
||||
<label>Reactive Keys<input type="checkbox" /></label>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
|
||||
max-width: 30cm;
|
||||
margin-block: auto;
|
||||
}
|
||||
|
||||
legend,
|
||||
legend > label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
|
||||
> input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 24px;
|
||||
|
||||
&:has(> legend input:not(:checked)) > :not(legend) {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> label {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
margin-block: 4px;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&:has(input[type="number"]) {
|
||||
cursor: text;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.unit {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 67px;
|
||||
padding-inline-end: auto;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
display: flex;
|
||||
|
||||
width: 5ch;
|
||||
height: 100%;
|
||||
padding-block: 4px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
text-align: end;
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
border: none;
|
||||
|
||||
&::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "bleh";
|
||||
}
|
||||
|
||||
&:focus {
|
||||
filter: brightness(120%);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<script>
|
||||
import {initSerial, serialPort} from "$lib/serial/connection"
|
||||
import Terminal from "$lib/components/Terminal.svelte"
|
||||
import {browser} from "$app/environment"
|
||||
</script>
|
||||
|
||||
<div class="device-grid">
|
||||
<div class="row">
|
||||
<a href=".." title="Close Terminal" class="icon" style="margin-inline-end: auto">arrow_back</a>
|
||||
{#if $serialPort === undefined}
|
||||
<button class="secondary" disabled={browser && !("serial" in navigator)} on:click={initSerial}>
|
||||
<span class="icon">usb</span>Pair
|
||||
</button>
|
||||
{/if}
|
||||
<button title="Reboot" class="icon" disabled={$serialPort === undefined}>restart_alt</button>
|
||||
<button title="Reboot to bootloader" class="icon" disabled={$serialPort === undefined}
|
||||
>rule_settings</button
|
||||
>
|
||||
</div>
|
||||
<div class="terminal">
|
||||
<Terminal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
|
||||
font-size: 1rem;
|
||||
color: var(--md-sys-color-on-background);
|
||||
text-decoration: none;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.icon {
|
||||
aspect-ratio: 1;
|
||||
padding-inline-end: 8px;
|
||||
font-size: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.device-grid {
|
||||
contain: size;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 16px;
|
||||
|
||||
width: calc(min(100%, 28cm));
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script>
|
||||
import {initSerial, serialPort} from "$lib/serial/connection"
|
||||
import Terminal from "$lib/components/Terminal.svelte"
|
||||
import {browser} from "$app/environment"
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>dot i/o device manager</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Device Manager</h1>
|
||||
|
||||
<div class="device-grid">
|
||||
<div class="row">
|
||||
{#if $serialPort === undefined}
|
||||
<button class="secondary" disabled={browser && !("serial" in navigator)} on:click={initSerial}>
|
||||
<span class="icon">usb</span>Pair
|
||||
</button>
|
||||
{/if}
|
||||
<button title="Reboot" class="icon" disabled={$serialPort === undefined}>restart_alt</button>
|
||||
<button title="Reboot to bootloader" class="icon" disabled={$serialPort === undefined}
|
||||
>rule_settings</button
|
||||
>
|
||||
</div>
|
||||
<div class="terminal">
|
||||
<Terminal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
|
||||
font-size: 1rem;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.icon {
|
||||
aspect-ratio: 1;
|
||||
padding-inline-end: 8px;
|
||||
font-size: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.device-grid {
|
||||
contain: size;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 16px;
|
||||
|
||||
width: calc(min(100%, 28cm));
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
186
src/routes/plugin/+page.svelte
Normal file
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import {onMount} from "svelte"
|
||||
import {basicSetup, EditorView} from "codemirror"
|
||||
import {javascript, javascriptLanguage} from "@codemirror/lang-javascript"
|
||||
import {defaultKeymap} from "@codemirror/commands"
|
||||
import {keymap} from "@codemirror/view"
|
||||
import {HighlightStyle, syntaxHighlighting} from "@codemirror/language"
|
||||
import {tags} from "@lezer/highlight"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
import type {CompletionContext} from "@codemirror/autocomplete"
|
||||
import {serialPort} from "$lib/serial/connection"
|
||||
import type {CharaDevice} from "$lib/serial/device"
|
||||
import examplePlugin from "./example-plugin.js?raw"
|
||||
|
||||
let theme = EditorView.baseTheme({
|
||||
".cm-editor .cm-content": {
|
||||
fontFamily: '"Noto Sans Mono", monospace',
|
||||
},
|
||||
".cm-FoldPlaceholder": {
|
||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface-variant)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface-variant)",
|
||||
borderColor: "var(--md-sys-color-outline)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--md-sys-color-tertiary)",
|
||||
color: "var(--md-sys-color-on-tertiary)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderColor: "var(--md-sys-color-on-background)",
|
||||
},
|
||||
".cm-selectionBackground": {
|
||||
background: "transparent !important",
|
||||
backdropFilter: "invert(0.3)",
|
||||
},
|
||||
})
|
||||
const highlightStyle = HighlightStyle.define(
|
||||
[
|
||||
{tag: tags.keyword, color: "var(--md-sys-color-primary)"},
|
||||
{tag: tags.number, color: "var(--md-sys-color-secondary)"},
|
||||
{tag: tags.string, color: "var(--md-sys-color-tertiary)"},
|
||||
{tag: tags.comment, color: "var(--md-sys-color-on-background)", opacity: 0.6},
|
||||
],
|
||||
{
|
||||
all: {fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px"},
|
||||
},
|
||||
)
|
||||
const completion = javascriptLanguage.data.of({
|
||||
autocomplete: function completeGlobals(context: CompletionContext) {
|
||||
if (context.matchBefore(/Chara\./)) {
|
||||
// TODO
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
editorView = new EditorView({
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
keymap.of(defaultKeymap),
|
||||
theme,
|
||||
syntaxHighlighting(highlightStyle),
|
||||
completion,
|
||||
],
|
||||
parent: editor,
|
||||
doc: examplePlugin,
|
||||
})
|
||||
})
|
||||
|
||||
const charaMethods = [
|
||||
"reboot",
|
||||
"bootloader",
|
||||
"getRamBytesAvailable",
|
||||
"getSetting",
|
||||
"setSetting",
|
||||
"getLayoutKey",
|
||||
"setLayoutKey",
|
||||
"deleteChord",
|
||||
"setChord",
|
||||
"getChordPhrase",
|
||||
"getChordCount",
|
||||
"getChord",
|
||||
"send",
|
||||
] satisfies Array<keyof CharaDevice>
|
||||
$: channels = $serialPort
|
||||
? ({
|
||||
getVersion: async (..._args: unknown[]) => $serialPort.version,
|
||||
getDevice: async (..._args: unknown[]) => $serialPort.device,
|
||||
commit: async (..._args: unknown[]) => {
|
||||
if (
|
||||
confirm(
|
||||
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
||||
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
|
||||
"Click OK to perform the commit anyways.",
|
||||
)
|
||||
) {
|
||||
return $serialPort.commit()
|
||||
}
|
||||
},
|
||||
...Object.fromEntries(charaMethods.map(it => [it, $serialPort[it].bind($serialPort)] as const)),
|
||||
} satisfies Record<string, Function>)
|
||||
: ({} as any)
|
||||
|
||||
async function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== "null" || event.source !== frame.contentWindow) return
|
||||
|
||||
const [channel, params] = event.data
|
||||
const response = channels[channel as keyof typeof channels](...params)
|
||||
frame.contentWindow!.postMessage({response: await response}, "*")
|
||||
}
|
||||
|
||||
function runPlugin() {
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
actionCodes: KEYMAP_CODES,
|
||||
script: editorView.state.doc.toString(),
|
||||
charaChannels: Object.keys(channels),
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}
|
||||
|
||||
let frame: HTMLIFrameElement
|
||||
let editor: HTMLDivElement
|
||||
let editorView: EditorView
|
||||
</script>
|
||||
|
||||
<svelte:window on:message={onMessage} />
|
||||
<section>
|
||||
<button on:click={runPlugin}><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button>
|
||||
<div class="editor-root" bind:this={editor} />
|
||||
</section>
|
||||
|
||||
<iframe
|
||||
aria-hidden="true"
|
||||
title="code sandbox"
|
||||
bind:this={frame}
|
||||
src="/sandbox.html"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: min-content;
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 8px;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
31
src/routes/plugin/example-plugin.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/*******************************
|
||||
* HOLD UP AND READ THIS FIRST *
|
||||
*******************************
|
||||
*
|
||||
* Chara devices have a LIMITED number of commits.
|
||||
* calling `Chara.commit()` can be a dangerous operation, which is why a confirmation dialog will be shown.
|
||||
* Devices are only rated for 10,000-25,000 commits, exceeding that limit may result in premature breakdowns.
|
||||
* `Chara.setSetting` or `Chara.setLayoutKey` is not affected by this, they last however only until the next boot.
|
||||
*
|
||||
* Chord writing is more forgiving, but keep in mind that excessive large-scale writing can still damage the device.
|
||||
*
|
||||
*/
|
||||
|
||||
const count = await Chara.getChordCount() // => 499
|
||||
const chord = await Chara.getChord(2) // => {actions: [1, 2, 3], phrase: [4, 5, 6]}
|
||||
|
||||
const setting = await Chara.getSetting(5) // => 0
|
||||
|
||||
// This, for example, would return all chords
|
||||
const chords = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
chords.push(await Chara.getChord(i))
|
||||
}
|
||||
|
||||
// You can also print values to the browser console (F12)
|
||||
console.log("Chords:", chords)
|
||||
|
||||
// You can access the actions by ID!
|
||||
Actions.SPACE // => {id: "SPACE", code: 32, icon: "space_bar", description: ...}
|
||||
Actions[32] // This also works
|
||||
Actions[0x20] // Or this!
|
||||
16
src/routes/pwa-setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {RegisterSWOptions} from "vite-plugin-pwa/types"
|
||||
|
||||
export async function initPwa(): Promise<string> {
|
||||
// @ts-expect-error confused TS
|
||||
const {pwaInfo} = await import("virtual:pwa-info")
|
||||
// @ts-expect-error confused TS
|
||||
const {registerSW} = await import("virtual:pwa-register")
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegisterError(error) {
|
||||
console.log("ServiceWorker Registration Error", error)
|
||||
},
|
||||
} satisfies RegisterSWOptions)
|
||||
|
||||
return pwaInfo ? pwaInfo.webManifest.linkTag : ""
|
||||
}
|
||||
0
src/routes/stats/+page.svelte
Normal file
21
src/routes/terminal/+page.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import Terminal from "$lib/components/Terminal.svelte"
|
||||
</script>
|
||||
|
||||
<section class="terminal">
|
||||
<Terminal />
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
contain: size;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 16px;
|
||||
|
||||
width: calc(min(100%, 28cm));
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||