146 Commits

Author SHA1 Message Date
44d89d3f35 1.3.1 2024-01-24 18:55:46 +01:00
eaf0adaf01 fix: sort legacy chord inputs 2024-01-24 18:55:31 +01:00
5b6a5ea36d 1.3.0 2024-01-20 22:24:39 +01:00
14cbb5553b feat: add auto-space info 2024-01-20 22:24:00 +01:00
duianto
8ed72fe958 fix: typo 2024-01-11 09:36:33 +01:00
06b83f79ef feat: add refresh button
resolves #82
2024-01-05 00:12:42 +01:00
5fa4b1fd09 1.2.0 2024-01-03 14:59:12 +01:00
f585a0ebda fix: disable Tauri publish for now 2024-01-03 14:50:51 +01:00
a48e2b5a16 fix: keyinfo missing display type prop 2024-01-03 14:21:37 +01:00
fd612eda1d fix: dynamic mappings are not displayed 2024-01-03 14:21:13 +01:00
a1fe6f7110 feat: periodically update os-layout in the background
fix: remove dead code in layout detection
fixes #78
resolves #79
2024-01-03 13:55:50 +01:00
0e57e810e0 feat: change icons 2024-01-03 01:26:39 +01:00
a15d5dde38 feat: inform user when save action failed
fixes #67
2023-12-30 16:04:16 +01:00
560206129e feat: add meta for config pages 2023-12-30 16:03:31 +01:00
cb7c70dac1 refactor: flatten visual key positioning system
fixes #74
fixes #43
2023-12-30 15:50:48 +01:00
edabf8ec84 fix: settings without min/max parse as 0
fixes #75
2023-12-30 12:50:46 +01:00
f2f61f32f2 feat: add reset options
resolves #70
2023-12-29 15:04:33 +01:00
a3857843d6 feat: use keycodes on CCX
resolves #71
2023-12-29 13:48:34 +01:00
c1b1068c4b fix: settings.yml missing hex prefix
feat: add direnv config
2023-12-29 13:23:41 +01:00
2411dd2bea feat: show dynamic key maps in layout view 2023-12-22 12:51:23 +01:00
7911904906 Revert "refactor: remove outdated files"
This reverts commit 84b22e0006.
2023-12-21 23:55:29 +01:00
630687de80 fix: use hexadecimal for settings 2023-12-21 23:49:55 +01:00
84b22e0006 refactor: remove outdated files 2023-12-21 23:39:23 +01:00
dd070c8856 feat: include source maps in pwa 2023-12-21 21:31:43 +01:00
6872cd0554 fix: build 2023-12-21 21:24:12 +01:00
628007af23 fix: align settings wording with gtm 2023-12-21 21:23:00 +01:00
19fad84357 fix: svg invalid 2023-12-21 21:20:26 +01:00
f172318a78 feat: update logo and favicon 2023-12-21 21:19:17 +01:00
c2e3850082 fix: maybe fix cloudflare pages loop 2023-12-21 20:47:06 +01:00
7a5a4eb434 feat: update pwa icon 2023-12-21 20:46:12 +01:00
c878311f62 feat: add web manifest to site meta 2023-12-21 20:28:47 +01:00
fb3fb246e9 fix: remove json files from pwa glob pattern 2023-12-21 20:19:55 +01:00
b4e4ca84a4 fix: pwa tries to include build-only files 2023-12-21 20:05:32 +01:00
c1b1544256 fix: mouse & scroll-speed options are unstyled
resolves #44
2023-12-21 19:42:37 +01:00
03dd528465 feat: add ability to add special actions to chord inputs
resolves #10
2023-12-21 19:19:47 +01:00
81af9f2e82 feat: add min/max enforcement to device settings
resolves #6
2023-12-21 18:31:50 +01:00
6bb42429e5 feat: add change indicator on settings page 2023-12-21 18:23:47 +01:00
d07751a944 fix: remove OS setting
resolves #61
2023-12-21 18:12:37 +01:00
8867030ede fix: PWA fixes 2023-12-21 18:08:16 +01:00
faaa6dd5be fix: keyboard action variant wrong 2023-12-21 16:11:02 +01:00
43cf13094e fix: "p" key missing for ccx and linked with "o" key 2023-12-20 20:01:28 +01:00
ed523628ff feat: try typing field in chords section
resolves #68
fix: "No Results" not translated
2023-12-18 18:42:08 +01:00
98b451eec9 1.1.0 2023-12-17 00:33:11 +01:00
6e37dc198f feat: rework character timeout setting 2023-12-16 18:13:02 +01:00
e319b1bfaf fix: swap top/bottom thumb labels
fixes #65
2023-12-16 15:27:34 +01:00
eb33b64100 feat: reject new chords that override another chord 2023-12-16 15:24:51 +01:00
766bc44a85 feat: do not use empty phrase for deleted chords 2023-12-16 15:20:44 +01:00
b679aa377a fix: key text showing focus outline
fix: layout selectable
2023-12-16 13:11:52 +01:00
ea3192d4e6 feat: add links to docs and dotio 2023-12-16 12:46:20 +01:00
256daec412 feat: chord modifier hints 2023-12-15 19:54:31 +01:00
29a07133d1 fix: deadlock 2023-12-15 16:59:06 +01:00
c3bd8431e5 feat: debounce connection suspension 2023-12-15 16:43:56 +01:00
c8e04ed6cc feat: auto-reconnect after reboot 2023-12-12 18:37:40 +01:00
d98653995b feat: bootloader warning
refactor: reword linux premission warning
2023-12-12 18:30:20 +01:00
3dd9611ebf feat: Linux permission guide 2023-12-12 18:03:34 +01:00
9d9360375b 1.0.0 2023-12-08 23:52:33 +01:00
d683c8c70c fix: action selector shows next item every time 2023-12-08 23:21:21 +01:00
d8d430f333 fix: browser warning referencing a non-existent app 2023-12-08 23:12:52 +01:00
fe850f47ec feat: add info for current and after next save action 2023-12-08 23:04:38 +01:00
f9a63a8724 fix: warn users if no device is connected
fix: can't backup without a device
2023-12-08 22:55:33 +01:00
af01426f43 fix: action tooltips not updating 2023-12-08 22:46:01 +01:00
9d7cefb3b4 fix: add ui when no device is connected
fixes #60
2023-12-08 22:38:46 +01:00
f44e5a79de fix: action selector search bar color
fixes #39
2023-12-08 22:30:03 +01:00
8b2e92c124 feat: add icons to unassigned cc1 3D keys
fixes #58
2023-12-08 22:26:32 +01:00
f758be91a9 fix: build 2023-12-08 22:16:07 +01:00
bf4c86e698 fix: legacy chords with commas and spaces 2023-12-08 22:10:49 +01:00
50a09d2008 fix: PWA not working 2023-12-08 22:02:48 +01:00
3c1a4de4a7 fix: chord page overlapping
fixes #57, fixes #56
fix: handle trailing spaces in lecacy chord files
2023-12-08 21:59:08 +01:00
8cbdf1393f fix: chord files not detected properly
feat: alert on unknown backups
2023-12-08 21:14:37 +01:00
1ccb17f053 fix: allow trailing linebreak for legacy layouts 2023-12-08 21:04:18 +01:00
532dc70fe2 feat: ccx layout 2023-12-08 14:49:07 +01:00
d5893013f9 feat: ccx layout maybe 2023-12-08 14:13:31 +01:00
80308cad73 feat: ccx layout (hopefully) 2023-12-07 21:52:04 +01:00
2d59bd016f feat: ccx row 2 2023-12-07 21:40:27 +01:00
298de49257 test ccx row 2023-12-07 21:36:31 +01:00
3a62864a41 feat: ccx key count 2023-12-07 21:29:26 +01:00
109095e35e feat: re-introduce background sync 2023-12-07 19:51:23 +01:00
2dd6f39ac6 fix: reword alt-code warnings
fixes #11
2023-12-07 19:36:10 +01:00
b0f653e73b fix: weird input behaviour on setting changes 2023-12-07 19:27:29 +01:00
d552fb9220 fix: only import settings that already exist 2023-12-07 19:06:49 +01:00
77339620e6 fix: full backups fail because of invalid setting IDs 2023-12-07 18:58:40 +01:00
846183bbb1 feat: compound chording actions 2023-12-06 01:19:01 +01:00
1d53f6df7a fix: crash with missing action info in chords 2023-12-06 00:31:08 +01:00
58d13a4107 feat: enable source maps in production builds 2023-12-05 17:29:15 +01:00
f7d99d8d7b feat: dynamic keymap prototype 2023-12-03 00:01:51 +01:00
d9dd003b01 feat: show warnings about shift and alt-code macros
resolves #38
2023-12-02 23:26:04 +01:00
dc798d2b9f v0.7.0 2023-12-02 21:23:34 +01:00
c2ec460c8c feat: alert user when connection failed, resolves #53 2023-12-02 21:18:56 +01:00
c51bcc8ff0 feat: legacy backup import, resolves #31 2023-12-02 21:16:22 +01:00
63b7f8ab18 feat: inform user when backup is incompatible, resolves #34 2023-12-02 21:03:14 +01:00
eaf8028538 feat: chord sharing url, resolves #40 2023-12-02 20:59:58 +01:00
2ad0ef3b6d feat: show info about key in chord manager and layout, resolves #51, resolves #52 2023-12-02 20:42:33 +01:00
20705de069 fix: editing chords bouces back to page 1, fixes #22 2023-12-02 20:31:39 +01:00
64b519d5b1 fix: can't type in the terminal 2023-12-02 20:25:10 +01:00
fb490b3db6 fix: page transitions can be buggy, fixes #55, #23 2023-12-02 20:24:42 +01:00
c37ae7da7b refactor: adjust wording for backups 2023-12-02 20:23:15 +01:00
5c06c2206c fix: undo/redo prevents use of unknown actions 2023-12-02 19:31:46 +01:00
f9cdf70bdb change save button styling
resolves #49
2023-11-29 14:31:19 +01:00
3a6483aa61 feat: combine save/apply
resolves #45
2023-11-29 01:08:46 +01:00
Priyanshu Tripathi
018c7a5eac fix: link to the new repository
The old repo will soon fall behind in terms of releases and the link will become outdated.
2023-11-28 17:35:43 +01:00
f73b8c1453 fix: set auto-connect to false by default
fixes #25
2023-11-26 22:17:52 +01:00
e38d952e1d fix: imported chords not filtered 2023-11-18 18:59:30 +01:00
8e5692ca59 fix: imported chords not filtered 2023-11-18 18:49:15 +01:00
a0fe925ea9 feat: basic chord trainer
fix: don't add chords from backup if identical chords already exist, fixes #30
2023-11-18 18:35:59 +01:00
e84470d577 fix: chord actions not sorted 2023-11-18 15:53:07 +01:00
683561dc06 feat: chord backup import 2023-11-18 11:21:50 +01:00
2fd2dad6f7 fix: chord maps are ordered incorrectly with new chords, fixes #24 2023-11-18 01:53:49 +01:00
e2f9f87b13 fix: very large toggles 2023-11-15 02:19:10 +01:00
623d895aea fix: broken site 2023-11-15 02:05:44 +01:00
561300de64 refactor: cleanup 2023-11-15 01:46:23 +01:00
c5d9defc9d feat: layout url import
feat: backup import (except chords)
feat: legacy layout import
feat: separate layout, chord & setting backup downloads
2023-11-15 01:14:34 +01:00
acd58646f6 feat: add generid 103-key layout for CCX users, fixes #12 2023-11-14 23:37:06 +01:00
3634264af3 fix: chentry disables unrelated settings, fixes #8 2023-11-14 23:03:34 +01:00
3515994a5a fix: settings page doesn't let you input more than one number, fixes #5 2023-11-14 23:00:18 +01:00
bdebe238ae feat: auto-show connect dialog when auto-connect is disabled, resolves #14 2023-11-14 22:51:59 +01:00
ebf7d73d20 feat: new blocking progress bar, fixes #18
feat: change cloud icon to history, fixes #15
fix: action search items overlap, fixes #16
feat: show tooltips immediately
2023-11-14 20:19:01 +01:00
e19a57efac feat: new chord button, fixes #9
feat: improved backups
2023-11-10 17:31:52 +01:00
034436f93e fix: editing chords messes up list 2023-11-10 16:05:42 +01:00
2710f7fc25 fix: phrase insert button not working 2023-11-10 16:00:34 +01:00
d2276a53d0 feat: chord editing 2023-11-10 15:45:04 +01:00
8701d7a40d fix: strikethrough not showing 2023-11-10 01:30:55 +01:00
94cfaf40e5 feat: new chord editing
feat: clear all changes with shift undo, fixes #7
2023-11-10 01:17:36 +01:00
c661a4b30b fix: chords can't be deleted 2023-11-03 23:13:56 +01:00
9b95e1d67a refactor: update branding
Fixes #4
2023-11-03 22:45:30 +01:00
f7bf93fcfc feat: chord editing prototype
feat: printing style for layout
2023-11-03 22:37:27 +01:00
08df049170 feat: rudimentary filter in action selector
Fixes #1
2023-11-03 18:57:22 +01:00
65a536cdea fix: chord deletion outputs empty string
Fixes #3
2023-11-03 18:26:58 +01:00
d2fd84a6b5 fix: add vendor ids for additional devices
fix: use proper semver parsing for device versions

Fixes #2
2023-11-03 18:24:32 +01:00
88429412b9 fix: lite breaks layout viewer 2023-11-02 22:13:37 +01:00
ef309d603e feat: editing 2023-11-02 00:16:18 +01:00
fade2f978e fix: cc1 layout keys 2023-11-01 17:03:38 +01:00
a1760d518c fix: layout keys 2023-11-01 16:40:18 +01:00
9d33565081 fix: search page pagination 2023-11-01 16:24:29 +01:00
Raymond Li
11fe12f095 Put CNAME into build.yml 2023-10-31 18:36:13 -04:00
aba390839b add cc1 visual layout 2023-10-31 23:27:44 +01:00
Raymond Li
a6e7df55ff Disable jekyll in build.yml 2023-10-31 18:12:16 -04:00
Raymond Li
7e5e7b8f5f Update build.yml 2023-10-31 17:50:43 -04:00
Raymond Li
a34ba35889 Remove extra checkout in build.yml 2023-10-31 17:49:54 -04:00
Raymond Li
616d15b6bd Merge jobs in build.yml 2023-10-31 17:48:59 -04:00
Raymond Li
283444f0be Fix build.yml 2023-10-31 17:44:31 -04:00
Raymond Li
e5e56c04a2 Update build.yml to deploy to GitHub pages 2023-10-31 17:34:10 -04:00
Raymond Li
a34c176bcc Update README.md to official CharaChorder implementation 2023-10-31 17:22:44 -04:00
e4d51cd51d visual layout adjustments 2023-10-31 22:09:33 +01:00
a7b49de6ac lite layout 2023-10-31 18:22:03 +01:00
fc86b31337 feat: chord editing prototype
feat: lazy device connections
feat: backup docs
feat: chord library pagination
2023-10-27 19:39:26 +02:00
d8f0679233 keyboard stuff, styling things 2023-09-25 18:12:34 +02:00
123 changed files with 4905 additions and 1746 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -7,8 +7,8 @@ on:
workflow_dispatch:
jobs:
build:
name: 🔨 Build
CI:
name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest
steps:
- name: 🚚 Checkout
@@ -39,25 +39,12 @@ jobs:
with:
name: build
path: build
deploy:
name: 🚀 Deploy
runs-on: ubuntu-latest
needs: build
environment:
name: Website
url: https://dotio.theaninova.de
steps:
- name: 📦 Download build artifacts
uses: actions/download-artifact@v2.1.1
with:
name: build
path: build
- name: 🚀 Deploy
uses: SamKirkland/web-deploy@v1
with:
target-server: ${{ secrets.SSH_SERVER }}
destination-path: ~/public_html/
source-path: ./build/
remote-user: ${{ secrets.SSH_USER }}
private-ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh-port: ${{ secrets.SSH_PORT }}
- name: Disable jekyll
run: touch build/.nojekyll
- name: Custom domain
run: echo 'manager.charachorder.com' > build/CNAME
- run: git config user.name github-actions
- run: git config user.email github-actions@github.com
- run: git --work-tree build add --all
- run: git commit -m "Automatic Deploy action run by github-actions"
- run: git push origin HEAD:gh-pages --force

View File

@@ -1,8 +1,8 @@
name: "publish"
name: "publish desktop apps"
on:
push:
tags:
- "v*"
- "desktop-app-v*"
workflow_dispatch:
jobs:

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules
/package
.env
.env.*
.direnv
!.env.example
venv
vite.config.js.timestamp-*

View File

@@ -1,15 +1,12 @@
# amaCC1ng
# CharaChorder Device Manager
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/Theaninova/dotio/build.yml)
![GitHub](https://img.shields.io/github/license/Theaninova/dotio)
[![GitHub deployments](https://img.shields.io/github/deployments/Theaninova/dotio/Website?label=delployment)](https://dotio.theaninova.de/)
The official device manager and configuration tool for CharaChorder devices.
_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/)_
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/CharaChorder/DeviceManager/build.yml)
![GitHub](https://img.shields.io/github/license/CharaChorder/DeviceManager)
[![GitHub deployments](https://img.shields.io/github/deployments/CharaChorder/DeviceManager/Website?label=delployment)](https://manager.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.
Get the latest desktop release [here](https://github.com/CharaChorder/DeviceManager/releases).
## Deployment

64
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,64 @@
# Chara Backup Format, Version 1
JSON Schema files: TBD
Chara backups are serialized using JSON, in this general format:
```json
{
"charaVersion": 1,
"type": "..."
}
```
The presence of the key `charaVersion` uniquely identifies the JSON file as a chara backup file and serves
as a discriminator against other generic JSON files. This key is mandatory for that reason.
## Type `layout`
```json
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layers": [[], [], []]
}
```
Devices at the current point in time may be identified as either `lite` or `one`, more to come in the future.
Layers are serialized as an array of `[layer1, layer2, layer3]` in the internal order of the key, each specifying
an action code. Action codes of `0` are considered unassigned.
## Type `chords`
```json
{
"charaVersion": 1,
"type": "chords",
"chords": [
[
[1, 2, 3],
[3, 4, 5]
],
[
[6, 7, 8],
[9, 10, 11]
]
]
}
```
Chords are serialized using a key-value mapping of chord action codes to actions.
## Type `settings`
```json
{
"charaVersion": 1,
"type": "settings",
"settings": [0, 1, 3, 6]
}
```
Settings are serialized as an array of the values in the way they appear on the device.

View File

@@ -4,31 +4,37 @@
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; [
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; [
])
++ (with tauriPkgs; [
curl
wget
pkg-config
@@ -39,16 +45,15 @@
libsoup
webkitgtk
librsvg
# serial plugin
udev
# serial plugin
udev
]);
in
{
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
});
in {
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
});
}

View File

@@ -1,15 +1,11 @@
export interface IconsConfig {
codePoints: Record<string, string>
inputPath: string
outputPath: string
icons: string[]
}
const config: IconsConfig = {
/** @type {import('./src/tools/icons-config').IconsConfig} */
const config = {
inputPath:
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2",
icons: [
"adjust",
"add",
"piano",
"keyboard",
"settings",
@@ -25,6 +21,7 @@ const config: IconsConfig = {
"cable",
"person",
"sync",
"school",
"restart_alt",
"usb",
"usb_off",
@@ -67,6 +64,35 @@ const config: IconsConfig = {
"bolt",
"undo",
"redo",
"navigate_before",
"navigate_next",
"print",
"restore_from_trash",
"history",
"history_toggle_off",
"sentiment_satisfied",
"sentiment_dissatisfied",
"sentiment_very_satisfied",
"sentiment_neutral",
"sentiment_very_dissatisfied",
"sentiment_excited",
"sentiment_frustrated",
"sentiment_calm",
"sentiment_stressed",
"sentiment_extremely_dissatisfied",
"sentiment_sad",
"sentiment_content",
"sentiment_worried",
"timer",
"target",
"download",
"download_2",
"upload_2",
"stat_minus_2",
"stat_2",
"description",
"add_circle",
"refresh",
],
codePoints: {
speed: "e9e4",
@@ -80,6 +106,11 @@ const config: IconsConfig = {
light_mode: "e518",
upload_file: "e9fc",
no_sound: "e710",
sentiment_extremely_dissatisfied: "f194",
download_2: "f523",
upload_2: "ff52",
stat_minus_2: "e69c",
stat_2: "e699",
},
}

188
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "amacc1ng",
"version": "0.6.5",
"name": "charachorder-device-manager",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "amacc1ng",
"version": "0.6.5",
"name": "charachorder-device-manager",
"version": "1.3.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"devDependencies": {
@@ -15,8 +15,8 @@
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.9.0",
"@codemirror/state": "^6.2.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.11",
"@fontsource-variable/noto-sans-mono": "^5.0.12",
"@fontsource-variable/material-symbols-rounded": "^5.0.16",
"@fontsource-variable/noto-sans-mono": "^5.0.17",
"@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@sveltejs/adapter-static": "^2.0.3",
@@ -28,6 +28,7 @@
"@types/dom-view-transitions": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/w3c-web-serial": "^1.0.3",
"@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.2.7",
"autoprefixer": "^10.4.15",
"codemirror": "^6.0.1",
@@ -35,6 +36,7 @@
"flexsearch": "^0.7.31",
"fontkit": "^2.0.2",
"glob": "^10.3.4",
"hotkeys-js": "^3.12.0",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
@@ -51,12 +53,11 @@
"svelte-check": "^3.5.1",
"svelte-preprocess": "^5.0.4",
"tippy.js": "^6.3.7",
"ts-node": "^10.9.1",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-plugin-mkcert": "^1.16.0",
"vite-plugin-pwa": "^0.16.5",
"vite-plugin-pwa": "^0.17.4",
"vitest": "^0.34.4"
}
},
@@ -1893,28 +1894,6 @@
"node": ">=0.1.90"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.1.tgz",
@@ -2416,15 +2395,15 @@
}
},
"node_modules/@fontsource-variable/material-symbols-rounded": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-rounded/-/material-symbols-rounded-5.0.11.tgz",
"integrity": "sha512-WelrZz3MJErCcMPFPJBWS8mL2dY80lnS/eKYisiiUp9dW2rsU/yULQ/ihf4fBtPc5v9PA/1Uh7gW/X/Bll6CuQ==",
"version": "5.0.16",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-rounded/-/material-symbols-rounded-5.0.16.tgz",
"integrity": "sha512-HtH/bpUBj/9irIouf2uPaB+qf6HKpR0JFxSDK2HGaqOLsJqIxs4RJB2Y9IXASwTN50FBd1g8KZ6O5vNYEsU94A==",
"dev": true
},
"node_modules/@fontsource-variable/noto-sans-mono": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-mono/-/noto-sans-mono-5.0.12.tgz",
"integrity": "sha512-OMDL6elwLMSEOdmWyRkA4ETGLyXv84LAtFPoZFj+N1pUy0L1om9Qz5f7DzwxdRA0HbciuJKRBa7XQGkMLjQZUg==",
"version": "5.0.17",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-mono/-/noto-sans-mono-5.0.17.tgz",
"integrity": "sha512-EpK1L28ZahAschdLmCCjHVoYNAystRlx/eduGKt9F6m4zln7x+CleAVWwqgAXOp/GDuTgVWwr1aPqcRFzwjQbg==",
"dev": true
},
"node_modules/@isaacs/cliui": {
@@ -3227,30 +3206,6 @@
"node": ">= 10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"dev": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@types/chai": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz",
@@ -3347,6 +3302,12 @@
"integrity": "sha512-R4J/OjqKAUFQoXVIkaUTfzb/sl6hLh/ZhDTfowJTRMa7LhgEmI/jXV4zsL1u8HpNa853BxwNmDIr0pauizzwSQ==",
"dev": true
},
"node_modules/@types/w3c-web-usb": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.10.tgz",
"integrity": "sha512-CHgUI5kTc/QLMP8hODUHhge0D4vx+9UiAwIGiT0sTy/B2XpdX1U5rJt6JSISgr6ikRT7vxV9EVAFeYZqUnl1gQ==",
"dev": true
},
"node_modules/@types/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
@@ -3632,12 +3593,6 @@
}
]
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -4518,12 +4473,6 @@
"url": "https://github.com/sponsors/d-fischer"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -5092,15 +5041,6 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"dev": true
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@@ -5527,9 +5467,9 @@
"dev": true
},
"node_modules/fast-glob": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -6273,6 +6213,12 @@
"node": ">=10"
}
},
"node_modules/hotkeys-js": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.12.0.tgz",
"integrity": "sha512-Z+N573ycUKIGwFYS3ID1RzMJiGmtWMGKMiaNLyJS8B1ei+MllF4ZYmKS2T0kMWBktOz+WZLVNikftEgnukOrXg==",
"dev": true
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
@@ -7622,12 +7568,6 @@
"node": ">=12"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
},
"node_modules/map-obj": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
@@ -10524,49 +10464,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
@@ -10909,12 +10806,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@@ -11036,13 +10927,13 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz",
"integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==",
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.17.4.tgz",
"integrity": "sha512-j9iiyinFOYyof4Zk3Q+DtmYyDVBDAi6PuMGNGq6uGI0pw7E+LNm9e+nQ2ep9obMP/kjdWwzilqUrlfVRj9OobA==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",
"fast-glob": "^3.3.1",
"fast-glob": "^3.3.2",
"pretty-bytes": "^6.1.1",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
@@ -11054,7 +10945,7 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vite": "^3.1.0 || ^4.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
}
@@ -11836,15 +11727,6 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,15 +1,15 @@
{
"name": "amacc1ng",
"version": "0.6.5",
"name": "charachorder-device-manager",
"version": "1.3.1",
"license": "AGPL-3.0-or-later",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/Theaninova/amacc1ng.git"
"url": "https://github.com/CharaChorder/DeviceManager.git"
},
"homepage": "https://github.com/Theaninova/amacc1ng",
"homepage": "https://docs.charachorder.com",
"bugs": {
"url": "https://github.com/Theaninova/amacc1ng/issues"
"url": "https://github.com/CharaChorder/DeviceManager/issues"
},
"scripts": {
"dev": "npm-run-all --parallel vite typesafe-i18n",
@@ -23,8 +23,8 @@
"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",
"version": "ts-node-esm src/tools/version.ts && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
"minify-icons": "node src/tools/minify-icon-font.js",
"version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write .",
"typesafe-i18n": "typesafe-i18n"
@@ -35,8 +35,8 @@
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.9.0",
"@codemirror/state": "^6.2.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.11",
"@fontsource-variable/noto-sans-mono": "^5.0.12",
"@fontsource-variable/material-symbols-rounded": "^5.0.16",
"@fontsource-variable/noto-sans-mono": "^5.0.17",
"@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@sveltejs/adapter-static": "^2.0.3",
@@ -48,6 +48,7 @@
"@types/dom-view-transitions": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/w3c-web-serial": "^1.0.3",
"@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.2.7",
"autoprefixer": "^10.4.15",
"codemirror": "^6.0.1",
@@ -55,6 +56,7 @@
"flexsearch": "^0.7.31",
"fontkit": "^2.0.2",
"glob": "^10.3.4",
"hotkeys-js": "^3.12.0",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
@@ -71,12 +73,11 @@
"svelte-check": "^3.5.1",
"svelte-preprocess": "^5.0.4",
"tippy.js": "^6.3.7",
"ts-node": "^10.9.1",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-plugin-mkcert": "^1.16.0",
"vite-plugin-pwa": "^0.16.5",
"vite-plugin-pwa": "^0.17.4",
"vitest": "^0.34.4"
},
"type": "module"

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/icon.svg" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>

8
src/env.d.ts vendored
View File

@@ -7,11 +7,13 @@ interface ImportMetaEnv {
readonly TAURI_ARCH?: string
readonly TAURI_DEBUG?: boolean
readonly TAURI_PLATFORM_TYPE?: string
readonly VITE_HOMEPAGE_URL: string
readonly VITE_BUGS_URL: string
readonly VITE_DOCS_URL: string
readonly VIET_LEARN_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const HOMEPAGE_URL: string
declare const BUGS_URL: string

View File

@@ -1,18 +1,27 @@
import type {Translation} from "../i18n-types"
const de = {
TITLE: "amaCC1ng",
TITLE: "CharaChorder Gerätemanager",
DESCRIPTION: "Gerätemanager und Konfigurationstool für CharaChorder Geräte.",
saveActions: {
UNDO: "Rückgängig",
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
REDO: "Wiederholen",
APPLY: "Anwenden",
SAVE: "Änderungen auf das Gerät schreiben",
SAVE: "Speichern",
},
update: {
TITLE: "Gerät aktualisieren",
},
sync: {
TITLE_READ: "Neueste Änderungen werden abgerufen",
TITLE_WRITE: "Änderungen werden gespeichert",
RELOAD: "Neu laden",
},
backup: {
TITLE: "Sicherungskopie",
TITLE: "Verlauf speichern",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER:
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.",
DOWNLOAD: "Kopie Speichern",
"Der Verlauf wird als Backup in diesem Browser gespeichert. Der Verlauf bleibt auf diesem Computer.",
DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen",
},
modal: {
@@ -21,12 +30,23 @@ const de = {
actionSearch: {
PLACEHOLDER: "Nach Aktionen suchen",
CURRENT_ACTION: "Aktuelle Aktion",
NEXT_ACTION: "Aktion nach dem nächsten Speichern",
DELETE: "Entfernen",
filter: {
ALL: "Alle",
},
LIVE_LAYOUT_INFO: "Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
},
share: {
TITLE: "Teilen",
URL_COPIED: "Teilbare URL kopiert!",
EXTRA_DOWNLOAD: "Als Datei herunterladen",
},
print: {
TITLE: "Drucken",
},
profile: {
TITLE: "Profil",
LANGUAGE: "Sprache",
@@ -44,10 +64,14 @@ const de = {
DISCONNECT: "Entfernen",
TERMINAL: "Konsole",
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
NO_DEVICE: "Kein Gerät verbunden",
LINUX_PERMISSIONS:
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden.",
bootMenu: {
TITLE: "Bootmenü",
REBOOT: "Neustarten",
BOOTLOADER: "Bootloader",
POWER_WARNING: "Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
},
},
browserWarning: {
@@ -60,14 +84,44 @@ const de = {
"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",
DOWNLOAD_APP:
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
},
changes: {
TITLE: "Änderungen importieren",
ALL_CHANGES: "Alle Änderungen",
layout: {
TITLE: "{0} veränderte Belegung{{:|en}}",
LAYER: "{changes} Belegung{{changes:|en}} in Ebene {layer} ändern",
},
settings: {
TITLE: "{0} Einstellung{{|en}} anpassen",
},
chords: {
TITLE: "{0} Akkorde",
NEW_CHORDS: "{0} neue Akkord{{|e}} hinzufügen",
CHANGED_CHORDS: "{0} Akkord{{|e}} ersetzen",
DELETED_CHORDS: "{0} Akkord{{|e}} zum löschen markieren",
},
},
configure: {
chords: {
TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits",
search: {
PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen",
NO_RESULTS: "Keine Ergebnisse",
},
conflict: {
TITLE: "Akkordkonflikt",
DESCRIPTION:
"Der Akkord {0} würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
CONFIRM: "Überschreiben",
ABORT: "Überspringen",
},
TRY_TYPING: "Versuche hier zu tippen",
},
layout: {
TITLE: "Layout",

View File

@@ -1,31 +1,51 @@
import type {BaseTranslation} from "../i18n-types"
const en = {
TITLE: "amaCC1ng",
TITLE: "CharaChorder Device Manager",
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.",
saveActions: {
UNDO: "Undo",
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo",
APPLY: "Apply",
SAVE: "Write changes to your device",
SAVE: "Save",
},
update: {
TITLE: "Update your device",
},
backup: {
TITLE: "Local Backup",
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.",
DOWNLOAD: "Download Backup",
TITLE: "Store History",
INDIVIDUAL: "Individual backups",
DISCLAIMER: "Your history is stored as a backup in this browser. The history remains on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore",
},
sync: {
TITLE_READ: "Reading latest changes",
TITLE_WRITE: "Saving changes to device",
RELOAD: "Reload",
},
modal: {
CLOSE: "Close",
},
actionSearch: {
PLACEHOLDER: "Search for actions",
CURRENT_ACTION: "Current action",
NEXT_ACTION: "Action after next save",
DELETE: "Remove",
filter: {
ALL: "All",
},
LIVE_LAYOUT_INFO: "This output was determined using on your system layout.",
SHIFT_WARNING: "This action holds <kbd class='icon'>shift</kbd>",
ALT_CODE_WARNING: "This alt-code macro only works on Windows",
},
share: {
TITLE: "Share",
URL_COPIED: "Sharable URL copied!",
EXTRA_DOWNLOAD: "Download as file",
},
print: {
TITLE: "Print",
},
profile: {
TITLE: "Profile",
LANGUAGE: "Language",
@@ -43,10 +63,13 @@ const en = {
DISCONNECT: "Disconnect",
TERMINAL: "Terminal",
APPLY_SETTINGS: "Flash changes to device",
NO_DEVICE: "No device connected",
LINUX_PERMISSIONS: "Most Linux based systems need adjusted permissions in order to connect your device.",
bootMenu: {
TITLE: "Boot Menu",
REBOOT: "Reboot",
BOOTLOADER: "Bootloader",
POWER_WARNING: "To reboot from bootloader you need to physically reconnect your device.",
},
},
browserWarning: {
@@ -58,14 +81,43 @@ const en = {
"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",
DOWNLOAD_APP: "Chrome or Edge are officially supported, but other browsers might work as well.",
},
changes: {
TITLE: "Import changes",
ALL_CHANGES: "All changes",
layout: {
TITLE: "{0} layout change{{|s}}",
LAYER: "Update {changes} key{{changes:|s}} in layer {layer}",
},
settings: {
TITLE: "Update {0} setting{{|s}}",
},
chords: {
TITLE: "{0} chords",
NEW_CHORDS: "Add {0} new chord{{|s}}",
CHANGED_CHORDS: "Replace {0} chord{{|s}}",
DELETED_CHORDS: "Mark {0} chord{{|s}} for deletion",
},
},
configure: {
chords: {
TITLE: "Chords",
HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists",
search: {
PLACEHOLDER: "Search {0} chord{{|s}}",
NO_RESULTS: "No results",
},
conflict: {
TITLE: "Chord conflict",
DESCRIPTION:
"Your chord {0} conflicts with an existing chord. Are you sure you want to overwrite this chord?",
CONFIRM: "Overwrite",
ABORT: "Skip",
},
TRY_TYPING: "Try typing here",
},
layout: {
TITLE: "Layout",

View File

@@ -0,0 +1,141 @@
name: ASCII Macros
description: ASCII Characters that are macros for SHFT + key
actions:
33:
id: "!"
title: Exclamation Point
34:
id: '"'
title: Double Quote
35:
id: "#"
title: Hash Symbol
36:
id: "$"
title: Dollar Sign
37:
id: "%"
title: Percent
38:
id: "&"
title: Ampersand
40:
id: "("
title: Opening Parenthesis
41:
id: ")"
title: Closing Parenthesis
42:
id: "*"
title: Asterisk
58:
id: ":"
title: Colon
60:
id: "<"
title: Less Than
62:
id: ">"
title: Greater Than
63:
id: "?"
title: Question Mark
64:
id: "@"
title: At Symbol
65:
id: "A"
title: Uppercase A
66:
id: "B"
title: Uppercase B
67:
id: "C"
title: Uppercase C
68:
id: "D"
title: Uppercase D
69:
id: "E"
title: Uppercase E
70:
id: "F"
title: Uppercase F
71:
id: "G"
title: Uppercase G
72:
id: "H"
title: Uppercase H
73:
id: "I"
title: Uppercase I
74:
id: "J"
title: Uppercase J
75:
id: "K"
title: Uppercase K
76:
id: "L"
title: Uppercase L
77:
id: "M"
title: Uppercase M
78:
id: "N"
title: Uppercase N
79:
id: "O"
title: Uppercase O
80:
id: "P"
title: Uppercase P
81:
id: "Q"
title: Uppercase Q
82:
id: "R"
title: Uppercase R
83:
id: "S"
title: Uppercase S
84:
id: "T"
title: Uppercase T
85:
id: "U"
title: Uppercase U
86:
id: "V"
title: Uppercase V
87:
id: "W"
title: Uppercase W
88:
id: "X"
title: Uppercase X
89:
id: "Y"
title: Uppercase Y
90:
id: "Z"
title: Uppercase Z
94:
id: "^"
title: Caret
95:
id: "_"
title: Underscore
123:
id: "{"
title: Left Curly Brace
124:
id: "|"
title: Pipe
125:
id: "}"
title: Right Curly Brace
126:
id: "~"
title: Tilde

View File

@@ -7,39 +7,9 @@ actions:
description: |
While SPACE is used for keymaps and chord, just a " " is used in chord outputs.
This action is unique in this way. Technically it is "printable", but it is not visible.
33:
id: "!"
title: Exclamation Point
34:
id: '"'
title: Double Quote
35:
id: "#"
title: Hash Symbol
36:
id: "$"
title: Dollar Sign
37:
id: "%"
title: Percent
38:
id: "&"
title: Ampersand
39:
id: "'"
title: Single Quote
40:
id: "("
title: Opening Parenthesis
41:
id: ")"
title: Closing Parenthesis
42:
id: "*"
title: Asterisk
43:
id: "+"
title: Plus
44:
id: ","
title: Comma
@@ -82,105 +52,12 @@ actions:
57:
id: "9"
title: Nine
58:
id: ":"
title: Colon
59:
id: ";"
title: Semicolon
60:
id: "<"
title: Less Than
61:
id: "="
title: Equals
62:
id: ">"
title: Greater Than
63:
id: "?"
title: Question Mark
64:
id: "@"
title: At Symbol
65:
id: "A"
title: Uppercase A
66:
id: "B"
title: Uppercase B
67:
id: "C"
title: Uppercase C
68:
id: "D"
title: Uppercase D
69:
id: "E"
title: Uppercase E
70:
id: "F"
title: Uppercase F
71:
id: "G"
title: Uppercase G
72:
id: "H"
title: Uppercase H
73:
id: "I"
title: Uppercase I
74:
id: "J"
title: Uppercase J
75:
id: "K"
title: Uppercase K
76:
id: "L"
title: Uppercase L
77:
id: "M"
title: Uppercase M
78:
id: "N"
title: Uppercase N
79:
id: "O"
title: Uppercase O
80:
id: "P"
title: Uppercase P
81:
id: "Q"
title: Uppercase Q
82:
id: "R"
title: Uppercase R
83:
id: "S"
title: Uppercase S
84:
id: "T"
title: Uppercase T
85:
id: "U"
title: Uppercase U
86:
id: "V"
title: Uppercase V
87:
id: "W"
title: Uppercase W
88:
id: "X"
title: Uppercase X
89:
id: "Y"
title: Uppercase Y
90:
id: "Z"
title: Uppercase Z
91:
id: "["
title: Left Bracket
@@ -190,12 +67,6 @@ actions:
93:
id: "]"
title: Right Bracket
94:
id: "^"
title: Caret
95:
id: "_"
title: Underscore
96:
id: "`"
title: Backtick
@@ -277,19 +148,6 @@ actions:
122:
id: "z"
title: Lowercase z
123:
id: "{"
title: Left Curly Brace
124:
id: "|"
title: Pipe
125:
id: "}"
title: Right Curly Brace
126:
id: "~"
title: Tilde
127:
id: "DEL"
title: Delete
icon: delete_forever

View File

@@ -6,34 +6,73 @@ type: unassigned
actions:
600:
id: "LH_THUMB_3_3D"
title: Left Hand Thumb Top 3D Click
title: "Left Hand Thumb Bottom 3D Click"
icon: "adjust"
601:
id: "LH_THUMB_2_3D"
title: Left Hand Thumb Middle 3D Click
title: "Left Hand Thumb Middle 3D Click"
icon: "adjust"
602:
id: "LH_THUMB_1_3D"
title: Left Hand Thumb Bottom 3D Click
title: "Left Hand Thumb Top 3D Click"
icon: "adjust"
603:
id: "LH_INDEX_3D"
title: Left Hand Index Finger 3D Click
title: "Left Hand Index Finger 3D Click"
icon: "adjust"
604:
id: "LH_MID_1_3D"
title: Left Hand Middle Finger 3D Click
title: "Left Hand Middle Finger 3D Click"
icon: "adjust"
605:
id: "LH_RING_1_3D"
title: Left Hand Ring Finger 3D Click
title: "Left Hand Ring Finger 3D Click"
icon: "adjust"
606:
id: "LH_PINKY_3D"
title: Left Hand Pinky 3D Click,
# TODO...
# ["607", "CharaChorder One", "LH_MID_2_3D", "", ""],
# ["608", "CharaChorder One", "LH_RING_2_3D", "", ""],
# ["609", "CharaChorder One", "RH_THUMB_3_3D", "", ""],
# ["610", "CharaChorder One", "RH_THUMB_2_3D", "", ""],
# ["611", "CharaChorder One", "RH_THUMB_1_3D", "", ""],
# ["612", "CharaChorder One", "RH_INDEX_3D", "", ""],
# ["613", "CharaChorder One", "RH_MID_1_3D", "", ""],
# ["614", "CharaChorder One", "RH_RING_1_3D", "", ""],
# ["615", "CharaChorder One", "RH_PINKY_3D", "", ""],
# ["616", "CharaChorder One", "RH_MID_2_3D", "", ""],
# ["617", "CharaChorder One", "RH_RING_2_3D", "", ""]
title: "Left Hand Pinky 3D Click"
icon: "adjust"
607:
id: "LH_MID_2_3D"
title: "Left Hand Middle Finger 2 3D Click"
icon: "adjust"
608:
id: "LH_RING_2_3D"
title: "Left Hand Ring Finger 2 3D Click"
icon: "adjust"
609:
id: "RH_THUMB_3_3D"
title: "Right Hand Thumb Bottom 3D Click"
icon: "adjust"
610:
id: "RH_THUMB_2_3D"
title: "Right Hand Thumb Middle 3D Click"
icon: "adjust"
611:
id: "RH_THUMB_1_3D"
title: "Right Hand Thumb Top 3D Click"
icon: "adjust"
612:
id: "RH_INDEX_3D"
title: "Right Hand Index Finger 3D Click"
icon: "adjust"
613:
id: "RH_MID_1_3D"
title: "Right Hand Middle Finger 3D Click"
icon: "adjust"
614:
id: "RH_RING_1_3D"
title: "Right Hand Ring Finger 3D Click"
icon: "adjust"
615:
id: "RH_PINKY_3D"
title: "Right Hand Pinky 3D Click"
icon: "adjust"
616:
id: "RH_MID_2_3D"
title: "Right Hand Middle Finger 2 3D Click"
icon: "adjust"
617:
id: "RH_RING_2_3D"
title: "Right Hand Ring Finger 2 3D Click"
icon: "adjust"

View File

@@ -26,7 +26,7 @@ actions:
536:
id: "DUP"
title: Repeat Last Note
icon: control_point_duplicate
icon: copy_all
description: |
In character entry, it repeats your last input.
In chorded entry, it is used for words with repeating letters.

View File

@@ -4,9 +4,9 @@ icon: keyboard
actions:
512: &left_ctrl
id: "LEFT_CTRL"
display: CTRL
title: Control Keyboard Modifier
variant: left
icon: keyboard_control_key
513: &left_shift
id: "LEFT_SHIFT"
title: Shift Keyboard Modifier
@@ -14,14 +14,14 @@ actions:
icon: shift
514: &left_alt
id: "LEFT_ALT"
display: ALT
title: Alt Keyboard Modifier
variant: left
icon: keyboard_option_key
515: &left_gui
id: "LEFT_GUI"
title: GUI Keyboard Modifier
icon: apps
variant: left
icon: keyboard_command_key
516:
variationOf: 512
<<: *left_ctrl
@@ -31,14 +31,17 @@ actions:
variationOf: 513
<<: *left_shift
id: "RIGHT_SHIFT"
variant: right
518:
variationOf: 514
<<: *left_alt
id: "RIGHT_ALT"
variant: right
519:
variationOf: 515
<<: *left_gui
id: "RIGHT_GUI"
variant: right
520:
id: "RELEASE_MOD"
title: Release all keyboard modifiers
@@ -51,3 +54,11 @@ actions:
id: "RELEASE_KEYS"
title: Release all keys, but not keyboard modifiers
icon: text_rotate_up
523:
id: "PRESS_NEXT"
title: "Press and do not release the next key/action"
icon: download
524:
id: "RELEASE_NEXT"
title: "Release the next key/action in the sequence"
icon: upload

View File

@@ -2,6 +2,7 @@ export interface KeymapCategory {
name: string
description: string
icon?: string
display?: string
type?: "unassigned"
actions: Record<number, Partial<ActionInfo>>
}
@@ -10,7 +11,9 @@ export interface ActionInfo {
id: string
title: string
icon: string
display: string
description: string
variant: "left" | "right"
variantOf: number
keyCode: string
}

View File

@@ -1,9 +1,11 @@
name: Raw Scancodes
description: Raw Keyboard Scancodes
name: Key codes
description: OS-Layout sensitive keycodes
actions:
256:
id: "KSC_00"
icon: block
title: No Key Pressed
description: Also commonly used at the end of a chord to remove auto-spaces
257:
id: "KSC_01"
title: Keyboard Error Roll Over
@@ -15,311 +17,408 @@ actions:
title: Keyboard Error Undefined
260:
id: "KEY_A"
keyCode: "KeyA"
title: Keyboard a and A (US English)
description: Non US English keyboard users may prefer these Raw Scancodes
261:
id: "KEY_B"
keyCode: "KeyB"
title: Keyboard b and B (US English)
262:
id: "KEY_C"
keyCode: "KeyC"
title: Keyboard c and C (US English)
263:
id: "KEY_D"
keyCode: "KeyD"
title: Keyboard d and D (US English)
264:
id: "KEY_E"
keyCode: "KeyE"
title: Keyboard e and E (US English)
265:
id: "KEY_F"
keyCode: "KeyF"
title: Keyboard f and F (US English)
266:
id: "KEY_G"
keyCode: "KeyG"
title: Keyboard g and G (US English)
267:
id: "KEY_H"
keyCode: "KeyH"
title: Keyboard h and H (US English)
268:
id: "KEY_I"
keyCode: "KeyI"
title: Keyboard i and I (US English)
269:
id: "KEY_J"
keyCode: "KeyJ"
title: Keyboard j and J (US English)
270:
id: "KEY_K"
keyCode: "KeyK"
title: Keyboard k and K (US English)
271:
id: "KEY_L"
keyCode: "KeyL"
title: Keyboard l and L (US English)
272:
id: "KEY_M"
keyCode: "KeyM"
title: Keyboard m and M (US English)
273:
id: "KEY_N"
keyCode: "KeyN"
title: Keyboard n and N (US English)
274:
id: "KEY_O"
keyCode: "KeyO"
title: Keyboard o and O (US English)
275:
id: "KEY_P"
keyCode: "KeyP"
title: Keyboard p and P (US English)
276:
id: "KEY_Q"
keyCode: "KeyQ"
title: Keyboard q and Q (US English)
277:
id: "KEY_R"
keyCode: "KeyR"
title: Keyboard r and R (US English)
278:
id: "KEY_S"
keyCode: "KeyS"
title: Keyboard s and S (US English)
279:
id: "KEY_T"
keyCode: "KeyT"
title: Keyboard t and T (US English)
280:
id: "KEY_U"
keyCode: "KeyU"
title: Keyboard u and U (US English)
281:
id: "KEY_V"
keyCode: "KeyV"
title: Keyboard v and V (US English)
282:
id: "KEY_W"
keyCode: "KeyW"
title: Keyboard w and W (US English)
283:
id: "KEY_X"
keyCode: "KeyX"
title: Keyboard x and X (US English)
284:
id: "KEY_Y"
keyCode: "KeyY"
title: Keyboard y and Y (US English)
285:
id: "KEY_Z"
keyCode: "KeyZ"
title: Keyboard z and Z (US English)
286:
id: "KEY_1"
keyCode: "Digit1"
title: Keyboard 1 and ! (US English)
287:
id: "KEY_2"
keyCode: "Digit2"
title: Keyboard 2 and @ (US English)
288:
id: "KEY_3"
keyCode: "Digit3"
title: Keyboard 3 and # (US English)
289:
id: "KEY_4"
keyCode: "Digit4"
title: Keyboard 4 and $ (US English)
290:
id: "KEY_5"
keyCode: "Digit5"
title: Keyboard 5 and % (US English)
291:
id: "KEY_6"
keyCode: "Digit6"
title: Keyboard 6 and ^ (US English)
292:
id: "KEY_7"
keyCode: "Digit7"
title: Keyboard 7 and & (US English)
293:
id: "KEY_8"
keyCode: "Digit8"
title: Keyboard 8 and * (US English)
294:
id: "KEY_9"
keyCode: "Digit9"
title: Keyboard 9 and ( (US English)
295:
id: "KEY_0"
keyCode: "Digit0"
title: Keyboard 0 and ) (US English)
296:
id: "ENTER"
keyCode: "Enter"
title: Keyboard Return (US English)
icon: keyboard_return
297:
id: "ESC"
keyCode: "Escape"
title: Keyboard Escape (US English)
298:
id: "BKSP"
keyCode: "Backspace"
title: Keyboard Backspace (US English)
icon: backspace
299:
id: "TAB"
keyCode: "Tab"
title: Keyboard Tab (US English)
icon: keyboard_tab
300:
id: "KSC_2C"
keyCode: "Space"
title: Keyboard Space (US English)
description: |
The ASCII space is preferred over this raw scancode for the space bar.
icon: space_bar
301:
id: "KSC_2D"
keyCode: "Minus"
title: Keyboard - and _ (US English)
302:
id: "KSC_2E"
keyCode: "Equal"
title: Keyboard = and + (US English)
303:
id: "KSC_2F"
keyCode: "BracketLeft"
title: Keyboard [ and { (US English)
304:
id: "KSC_30"
keyCode: "BracketRight"
title: Keyboard ] and } (US English)
305:
id: "KSC_31"
keyCode: "Backslash"
title: Keyboard \ and | (US English)
306:
id: "KSC_32"
# TODO: also backslash?
title: Keyboard Non-US \# and ~ (US English)
307:
id: "KSC_33"
keyCode: "Semicolon"
title: "Keyboard ; and : (US English)"
308:
id: "KSC_34"
keyCode: "Quote"
title: Keyboard ' and " (US English)
309:
id: "KSC_35"
keyCode: "Backquote"
title: Keyboard ` and ~ (US English)
310:
id: "KSC_36"
keyCode: "Comma"
title: Keyboard , and < (US English)
311:
id: "KSC_37"
keyCode: "Period"
title: Keyboard . and > (US English)
312:
id: "KSC_38"
keyCode: "Slash"
title: Keyboard / and ? (US English)
313:
id: "CAPSLOCK"
keyCode: "CapsLock"
title: Keyboard Caps Lock
icon: shift_lock
314:
id: "F1"
keyCode: "F1"
title: Keyboard F1
315:
id: "F2"
keyCode: "F2"
title: Keyboard F2
316:
id: "F3"
keyCode: "F3"
title: Keyboard F3
317:
id: "F4"
keyCode: "F4"
title: Keyboard F4
318:
id: "F5"
keyCode: "F5"
title: Keyboard F5
319:
id: "F6"
keyCode: "F6"
title: Keyboard F6
320:
id: "F7"
keyCode: "F7"
title: Keyboard F7
321:
id: "F8"
keyCode: "F8"
title: Keyboard F8
322:
id: "F9"
keyCode: "F9"
title: Keyboard F9
323:
id: "F10"
keyCode: "F10"
title: Keyboard F10
324:
id: "F11"
keyCode: "F11"
title: Keyboard F11
325:
id: "F12"
keyCode: "F12"
title: Keyboard F12
326:
id: "PRTSCN"
keyCode: "PrintScreen"
title: Keyboard Print Screen
icon: screenshot_monitor
327:
id: "SCRLK"
keyCode: "ScrollLock"
title: Keyboard Scroll Lock
328:
id: "PAUSE"
keyCode: "Pause"
title: Keyboard Pause
329:
id: "INSERT"
keyCode: "Insert"
title: Keyboard Insert
icon: insert_text
330:
id: "HOME"
keyCode: "Home"
title: Keyboard Home
icon: home
331:
id: "PGUP"
keyCode: "PageUp"
title: Keyboard Page Up
icon: move_up
332:
id: "DELETE"
keyCode: "Delete"
title: Keyboard Delete Forward
333:
id: "END"
keyCode: "End"
title: Keyboard End
334:
id: "PGDN"
keyCode: "PageDown"
title: Keyboard Page Down
icon: move_down
335:
id: "ARROW_RT"
keyCode: "ArrowRight"
title: Keyboard Right Arrow
icon: keyboard_arrow_right
336:
id: "ARROW_LF"
keyCode: "ArrowLeft"
title: Keyboard Left Arrow
icon: keyboard_arrow_left
337:
id: "ARROW_DN"
keyCode: "ArrowDown"
title: Keyboard Down Arrow
icon: keyboard_arrow_down
338:
id: "ARROW_UP"
keyCode: "ArrowUp"
title: Keyboard Up Arrow
icon: keyboard_arrow_up
339:
id: "NUMLOCK"
keyCode: "NumLock"
title: Keyboard Num Lock and Clear
340:
id: "KP_SLASH"
keyCode: "NumpadDivide"
title: Keypad /
341:
id: "KP_ASTER"
keyCode: "NumpadStar"
title: Keypad *
342:
id: "KP_MINUS"
keyCode: "NumpadSubtract"
title: Keypad -
343:
id: "KP_PLUS"
keyCode: "NumpadAdd"
title: Keypad +
344:
id: "KP_ENTER"
keyCode: "NumpadEnter"
title: Keypad Enter
345:
id: "KP_1"
keyCode: "Numpad1"
title: Keypad 1 and End
346:
id: "KP_2"
keyCode: "Numpad2"
title: Keypad 2 and Down Arrow
347:
id: "KP_3"
keyCode: "Numpad3"
title: Keypad 3 and Page Down
348:
id: "KP_4"
keyCode: "Numpad4"
title: Keypad 4 and Left Arrow
349:
id: "KP_5"
keyCode: "Numpad5"
title: Keypad 5
350:
id: "KP_6"
keyCode: "Numpad6"
title: Keypad 6 and Rigth Arrow
351:
id: "KP_7"
keyCode: "Numpad7"
title: Keypad 7 and Home
352:
id: "KP_8"
keyCode: "Numpad8"
title: Keypad 8 and Up Arrow
353:
id: "KP_9"
keyCode: "Numpad9"
title: Keypad 9 and Page Up
354:
id: "KP_0"
keyCode: "Numpad0"
title: Keypad 0 and Insert
355:
id: "KP_DOT"
keyCode: "NumpadDecimal"
title: Keypad . and Delete
356:
id: "KSC_64"
keyCode: "IntlBackslash"
title: Keyboard Non-US \ and | (US English)
357:
id: "COMPOSE"
@@ -327,10 +426,12 @@ actions:
description: Officially supported by Win, Unix, and Boot
358:
id: "POWER"
keyCode: "Power"
title: Keyboard Power
description: Only officially supported by Mac and Unix
359:
id: "KP_EQUAL"
keyCode: "NumpadEqual"
title: Keypad =
description: Only officially supported by Mac
360:
@@ -787,10 +888,12 @@ actions:
description: Not required to be supported by any OS
472:
id: "KSC_D8"
keyCode: "NumpadClear"
title: Keypad Clear
description: Not required to be supported by any OS
473:
id: "KSC_D9"
keyCode: "NumpadClearEntry"
title: Keypad Clear Entry
description: Not required to be supported by any OS
474:
@@ -817,58 +920,74 @@ actions:
description: Not required to be supported by any OS
480:
id: "KSC_E0"
keyCode: "ControlLeft"
title: Keyboard Left Control
481:
id: "KSC_E1"
keyCode: "ShiftLeft"
title: Keyboard Left Shift
482:
id: "KSC_E2"
keyCode: "AltLeft"
title: Keyboard Left Alt
483:
id: "KSC_E3"
keyCode: "MetaLeft"
title: Keyboard Left GUI
484:
id: "KSC_E4"
keyCode: "ControlRight"
title: Keyboard Right Control
485:
id: "KSC_E5"
keyCode: "ShiftRight"
title: Keyboard Right Shift
486:
id: "KSC_E6"
keyCode: "AltRight"
title: Keyboard Right Alt
487:
id: "KSC_E7"
keyCode: "MetaRight"
title: Keyboard Right GUI
488:
id: "KSC_E8"
keyCode: "MediaPlayPause"
title: Media Play Pause
description: Not required to be supported by any OS. Possibly deprecated.
489:
id: "KSC_E9"
keyCode: "MediaStop"
title: Media Stop CD
description: Not required to be supported by any OS. Possibly deprecated.
490:
id: "KSC_EA"
keyCode: "MediaTrackPrevious"
title: Media Previous Song
description: Not required to be supported by any OS. Possibly deprecated.
491:
id: "KSC_EB"
keyCode: "MediaTrackNext"
title: Media Next Song
description: Not required to be supported by any OS. Possibly deprecated.
492:
id: "KSC_EC"
keyCode: "Eject"
title: Media Eject CD
description: Not required to be supported by any OS. Possibly deprecated.
493:
id: "KSC_ED"
keyCode: "AudioVolumeUp"
title: Media Volume Up
description: Not required to be supported by any OS. Possibly deprecated.
494:
id: "KSC_EE"
keyCode: "AudioVolumeDown"
title: Media Volume Down
description: Not required to be supported by any OS. Possibly deprecated.
495:
id: "KSC_EF"
keyCode: "AudioVolumeMute"
title: Media Mute
description: Not required to be supported by any OS. Possibly deprecated.
496:
@@ -877,18 +996,22 @@ actions:
description: Not required to be supported by any OS. Possibly deprecated.
497:
id: "KSC_F1"
keyCode: "BrowserBack"
title: Media Back
description: Not required to be supported by any OS. Possibly deprecated.
498:
id: "KSC_F2"
keyCode: "BrowserForward"
title: Media Forward
description: Not required to be supported by any OS. Possibly deprecated.
499:
id: "KSC_F3"
keyCode: "BrowserStop"
title: Media Stop
description: Not required to be supported by any OS. Possibly deprecated.
500:
id: "KSC_F4"
keyCode: "BrowserSearch"
title: Media Find
description: Not required to be supported by any OS. Possibly deprecated.
501:
@@ -905,14 +1028,17 @@ actions:
description: Not required to be supported by any OS. Possibly deprecated.
504:
id: "KSC_F8"
keyCode: "Sleep"
title: Media Sleep
description: Not required to be supported by any OS. Possibly deprecated.
505:
id: "KSC_F9"
keyCode: "WakeUp"
title: Media Coffee
description: Not required to be supported by any OS. Possibly deprecated.
506:
id: "KSC_FA"
keyCode: "BrowserRefresh"
title: Media Refresh
description: Not required to be supported by any OS. Possibly deprecated.
507:

View File

@@ -1,36 +0,0 @@
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}

View File

@@ -0,0 +1,142 @@
name: 103-key
col:
- row:
- key: 41
- key: 58
offset: [1, 0]
- key: 59
- key: 60
- key: 61
- key: 62
offset: [0.5, 0]
- key: 63
- key: 64
- key: 65
- key: 66
offset: [0.5, 0]
- key: 67
- key: 68
- key: 69
- key: 70
offset: [0.25, 0]
- key: 71
- key: 72
- offset: [0, 0.25]
row:
- key: 53
- key: 30
- key: 31
- key: 32
- key: 33
- key: 34
- key: 35
- key: 36
- key: 37
- key: 38
- key: 39
- key: 45
- key: 46
- key: 42
size: [2, 1]
- key: 73
offset: [0.25, 0]
- key: 74
- key: 75
- key: 83
offset: [0.25, 0]
- key: 84
- key: 85
- key: 86
- row:
- key: 43
size: [1.5, 1]
- key: 20
- key: 26
- key: 8
- key: 21
- key: 23
- key: 28
- key: 24
- key: 12
- key: 18
- key: 19
- key: 47
- key: 48
- key: 40
size: [1.5, 1]
- key: 76
offset: [0.25, 0]
- key: 77
- key: 78
- key: 95
offset: [0.25, 0]
- key: 96
- key: 97
- key: 87
size: [1, 2]
- offset: [0, -1]
row:
- key: 57
size: [2, 1]
- key: 4
- key: 22
- key: 7
- key: 9
- key: 10
- key: 11
- key: 13
- key: 14
- key: 15
- key: 51
- key: 52
- key: 49
size: [2, 1]
- key: 92
offset: [3.5, 0]
- key: 93
- key: 94
- row:
- key: 225
size: [2.5, 1]
- key: 29
- key: 27
- key: 6
- key: 25
- key: 5
- key: 17
- key: 16
- key: 54
- key: 55
- key: 56
- key: 229
size: [2.5, 1]
- key: 82
offset: [1.25, 0]
- key: 89
offset: [1.25, 0]
- key: 90
- key: 91
- key: 88
size: [1, 2]
- offset: [0, -1]
row:
- key: 224
size: [1.5, 1]
- key: 227
- key: 226
size: [1.5, 1]
- key: 44
size: [7, 1]
- key: 230
size: [1.5, 1]
- key: 231
- key: 228
size: [1.5, 1]
- key: 80
offset: [0.25, 0]
- key: 81
- key: 79
- key: 98
offset: [0.25, 0]
size: [2, 1]
- key: 99

View File

@@ -0,0 +1,87 @@
name: Lite
col:
- row:
- key: 53
- key: 54
- key: 55
- key: 56
- key: 57
- key: 58
- key: 59
- key: 60
- key: 61
- key: 62
- key: 63
- key: 64
- key: 65
- key: 66
size: [ 2, 1 ]
- row:
- key: 39
size: [ 1.5, 1 ]
- key: 40
- key: 41
- key: 42
- key: 43
- key: 44
- key: 45
- key: 46
- key: 47
- key: 48
- key: 49
- key: 50
- key: 51
- key: 52
size: [ 1.5, 1 ]
- row:
- key: 26
size: [ 1.75, 1 ]
- key: 27
- key: 28
- key: 29
- key: 30
- key: 31
- key: 32
- key: 33
- key: 34
- key: 35
- key: 36
- key: 37
- key: 38
size: [ 2.25, 1 ]
- row:
- key: 12
size: [ 2, 1 ]
- key: 13
- key: 14
- key: 15
- key: 16
- key: 17
- key: 18
- key: 19
- key: 20
- key: 21
- key: 22
- key: 23
- key: 24
- key: 25
- row:
- key: 0
- key: 1
size: [ 1.25, 1 ]
- key: 2
size: [ 1.25, 1 ]
- key: 3
size: [ 2, 1 ]
- key: 4
- key: 5
- key: 6
size: [ 2, 1 ]
- key: 7
size: [ 1.25, 1 ]
- key: 8
size: [ 1.25, 1 ]
- key: 9
- key: 10
- key: 11

View File

@@ -0,0 +1,42 @@
name: CC1
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }
- row:
- offset: [3.5, -0.25]
switch: { d: 0, e: 1, n: 2, w: 3, s: 4 }
- offset: [5, -0.25]
switch: { d: 45, w: 46, n: 47, e: 48, s: 49 }

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<mask id="cross" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="32" fill="white" />
<path d="M0 0L32 32M0 32L32 0" stroke="black" stroke-width="3" />
</mask>
<circle cx="16" cy="16" r="11.5" fill="none" stroke="white" stroke-width="9" mask="url(#cross)" />
</svg>

Before

Width:  |  Height:  |  Size: 433 B

118
src/lib/assets/settings.yml Normal file
View File

@@ -0,0 +1,118 @@
settings:
0x1:
title: Enable Serial Header
description: boolean 0 or 1, default is 0
0x2:
title: Enable Serial Logging
description: boolean 0 or 1, default is 0
0x3:
title: Enable Serial Debugging
description: boolean 0 or 1, default is 0
0x4:
title: Enable Serial Raw
description: boolean 0 or 1, default is 0
0x5:
title: Enable Serial Chord
description: boolean 0 or 1, default is 0
0x6:
title: Enable Serial Keyboard
description: boolean 0 or 1, default is 0
0x7:
title: Enable Serial Mouse
description: boolean 0 or 1, default is 0
0x11:
title: Enable USB HID Keyboard
description: boolean 0 or 1, default is 1
0x12:
title: Enable Character Entry
description: boolean 0 or 1
0x13:
title: GUI-CTRL Swap Mode
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
0x14:
title: Key Scan Duration
description: scan rate described in milliseconds; default is 2ms = 500Hz
0x15:
title: Key Debounce Press Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
0x16:
title: Key Debounce Release Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
0x17:
title: Keyboard Output Character Microsecond Delays
description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
0x21:
title: Enable USB HID Mouse
description: boolean 0 or 1; default is 1
0x22:
title: Slow Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
0x23:
title: Fast Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
0x24:
title: Enable Active Mouse
description: boolean 0 or 1; moves mouse back and forth every 60s
0x25:
title: Mouse Scroll Speed
description: default is 1; polls at 1/4th the rate of the mouse move updates
0x26:
title: Mouse Poll Duration
description: poll rate described in milliseconds; default is 20ms = 50Hz
0x31:
title: Enable Chording
description: boolean 0 or 1
0x32:
title: Enable Chording Character Counter Timeout
description: boolean 0 or 1; default is 1
0x33:
title: Chording Character Counter Timeout Timer
description: 0-255 deciseconds; default is 40 or 4.0 seconds
0x34:
title: Chord Detection Press Tolerance(ms)
description: 1-50 milliseconds
0x35:
title: Chord Detection Release Tolerance(ms)
description: 1-50 milliseconds
0x41:
title: Enable Spurring
description: boolean 0 or 1; default is 1
0x42:
title: Enable Spurring Character Counter Timeout
description: boolean 0 or 1; default is 1
0x43:
title: Spurring Character Counter Timeout Timer
description: 0-255 seconds; default is 240
0x51:
title: Enable Arpeggiates
description: boolean 0 or 1; default is 1
0x54:
title: Arpeggiate Tolerance
description: in milliseconds; default 800ms
0x61:
title: Enable Compound Chording (coming soon)
description: boolean 0 or 1; default is 0
0x64:
title: Compound Tolerance
description: in milliseconds; default 1500ms
0x81:
title: LED Brightness
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
0x82:
title: LED Color Code
description: Color Codes to be listed (CCL only)
0x83:
title: Enable LED Key Highlight (coming soon)
description: boolean 0 or 1 (CCL only)
0x84:
title: Enable LEDs
description: boolean 0 or 1; default is 1 (CCL only)
0x91:
title: Operating System
description: Operating system codes listed below
0x92:
title: Enable Realtime Feedback
description: boolean 0 or 1; default is 1
0x93:
title: Enable CharaChorder Ready on startup
description: boolean 0 or 1; default is 1

164
src/lib/backup/backup.ts Normal file
View File

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

View File

@@ -0,0 +1,26 @@
e + b + a,babe
e + c + b,because
f + e + c + a,face
h + e + c + a,each
i + d + ',I'd
i + g + b,big
i + g + e,give
k + b + a,back
k + e + a,take
l + e + a,late
l + e + d + a,lead
l + f + e,feel
l + g + e + a,large
l + h + e,help
l + i + a,Lia
l + i + f,fill
l + i + f + e,life
l + i + g + b + a,gitlab
l + k + i + e,like
m + e + a,make
m + i + ',I'm
n + c + a,can
n + d + a,and
n + e + b,been
n + e + b + a,enable
n + e + d,end
1 e + b + a babe
2 e + c + b because
3 f + e + c + a face
4 h + e + c + a each
5 i + d + ' I'd
6 i + g + b big
7 i + g + e give
8 k + b + a back
9 k + e + a take
10 l + e + a late
11 l + e + d + a lead
12 l + f + e feel
13 l + g + e + a large
14 l + h + e help
15 l + i + a Lia
16 l + i + f fill
17 l + i + f + e life
18 l + i + g + b + a gitlab
19 l + k + i + e like
20 m + e + a make
21 m + i + ' I'm
22 n + c + a can
23 n + d + a and
24 n + e + b been
25 n + e + b + a enable
26 n + e + d end

View File

@@ -0,0 +1,26 @@
e + b + a,babe
e + c + b,because
f + e + c + a,face
h + e + c + a,each
i + d + ',I'd
i + g + b,big
i + g + e,give
k + b + a,back
k + e + a,take
l + e + a,late
l + e + d + a,lead
l + f + e,feel
l + g + e + a,large
l + h + e,help
l + i + a,Lia
l + i + f,fill
l + i + f + e,life
l + i + g + b + a,gitlab
l + k + i + e,like
m + e + a,make
m + i + ',I'm
n + c + a,can
n + d + a,and
n + e + b,been
n + e + b + a,enable
n + e + d,end

View File

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

View File

@@ -11,7 +11,7 @@ export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] =
layout: [[], [], []],
}
for (const layer of csv.split("\n")) {
for (const layer of csv.trim().split("\n")) {
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import {action as title} from "$lib/title"
import {osLayout} from "$lib/os-layout"
import LL from "../../i18n/i18n-svelte"
export let action: number | KeyInfo
export let display: "inline-keys" | "keys" = "inline-keys"
$: info = typeof action === "number" ? KEYMAP_CODES[action] ?? {code: action} : action
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode)
$: tooltip =
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
(info.variant === "left" ? " (left)" : info.variant === "right" ? " (right)" : "")
</script>
{#if dynamicMapping}
<span
use:title={{title: $LL.actionSearch.LIVE_LAYOUT_INFO()}}
class="dynamic"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:inline={display === "inline-keys"}>{dynamicMapping}</span
>
{:else if display === "keys"}
<kbd
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{title: tooltip}}
>
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
</kbd>
{:else if display === "inline-keys"}
{#if !info.icon && info.id?.length === 1}
<span class:left={info.variant === "left"} class:right={info.variant === "right"}>{info.id}</span>
{:else}
<kbd
class="inline-kbd"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{title: tooltip}}
>
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}</kbd
>
{/if}
{/if}
<style lang="scss">
kbd:not(.inline-kbd) {
height: 24px;
padding-block: auto;
transition: color 250ms ease;
}
.left {
border-left-width: 3px;
}
.right {
border-right-width: 3px;
}
.dynamic {
padding: 4px;
border-radius: 1px;
min-width: 8px;
background: var(--md-sys-color-surface-variant);
&.inline {
padding: 0px;
}
}
.inline-kbd {
margin-inline-end: 2px;
}
:global(span) + .inline-kbd {
margin-inline-start: 2px;
}
</style>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import LL from "../../i18n/i18n-svelte"
import Action from "$lib/components/Action.svelte"
export let id: number | KeyInfo
@@ -21,8 +23,14 @@
{#if key.description}
<i>{key.description}</i>
{/if}
{#if key.category.name === "ASCII Macros"}
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
{/if}
{#if key.category.name === "CP-1252"}
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
{/if}
</div>
<span class:icon={!!key.icon} class="key">{key.icon || key.id || `0x${key.code.toString(16)}`}</span>
<Action display="keys" action={key} />
{:else}
<span class="key">0x{key.toString(16)}</span>
{/if}
@@ -35,6 +43,7 @@
align-items: center;
width: 100%;
height: auto;
margin: 0;
padding: 8px;
@@ -62,17 +71,14 @@
text-align: start;
}
.key {
.warning {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--md-sys-color-error);
min-width: 32px;
padding: 4px;
font-weight: 600;
border: 1px solid currentcolor;
border-radius: 4px;
> :global(.icon) {
font-size: 16px;
}
}
</style>

View File

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

View File

@@ -5,7 +5,9 @@
</script>
{#if $needRefresh}
<button title="Update ready" class="icon" on:click={() => updateServiceWorker(true)}>update</button>
<button title="Update ready" on:click={() => updateServiceWorker(true)}
>Update <span class="icon">update</span></button
>
{:else if $offlineReady}
<div title="App can now be used offline" class="icon">offline_pin</div>
{/if}

View File

@@ -16,7 +16,13 @@
<form on:submit={submit}>
<div bind:this={io} class="io">
{#each $serialLog as { type, value }}
<p class={type} transition:slide>{value}</p>
{#if type === "input"}
<code transition:slide>{value}</code>
{:else if type === "output"}
<samp transition:slide>{value}</samp>
{:else}
<p transition:slide>{value}</p>
{/if}
{/each}
<div class="anchor" />
</div>
@@ -111,17 +117,15 @@
height: 1px;
}
code,
samp,
p {
display: block;
overflow-anchor: none;
margin-block: 0.15rem;
}
p.input {
margin-block-end: 0.25rem;
font-weight: bold;
}
p.system {
p {
display: flex;
justify-content: center;
@@ -134,8 +138,9 @@
border-radius: 8px;
}
p.input::before {
code::before {
content: "> ";
margin-block-end: 0.25rem;
font-weight: 900;
color: var(--md-sys-color-primary);
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
export let title: string | undefined
export let shortcut: string | undefined
</script>
{#if title}
<p>{@html title}</p>
{/if}
{#if shortcut}
<kbd>
{#each shortcut.split("+") as key}
<kbd>{key}</kbd>
{/each}
</kbd>
{/if}
<style lang="scss">
p {
margin-block: 0;
}
</style>

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import {KEYMAP_CATEGORIES, KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import {createEventDispatcher} from "svelte"
import ActionListItem from "$lib/components/ActionListItem.svelte"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
export let currentAction: number
export let currentAction: number | undefined = undefined
export let nextAction: number | undefined = undefined
const index = new Index({tokenize: "full"})
for (const action of Object.values(KEYMAP_CODES)) {
@@ -38,10 +40,6 @@
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact)
} else if (event.shiftKey && event.key === "Escape") {
dispatch("select", 0)
} else if (event.key === "Escape") {
dispatch("close")
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
@@ -62,24 +60,25 @@
event.preventDefault()
}
let results: number[] = []
let results: number[] = Object.keys(KEYMAP_CODES).map(Number)
let exact: number | undefined = undefined
let code: number = Number.NaN
const dispatch = createEventDispatcher()
let searchBox: HTMLInputElement
let resultList: HTMLUListElement
let filter: Set<number>
</script>
<svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog open on:click|self={() => dispatch("close")}>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
autofocus
on:input={search}
on:keypress={event => {
if (event.key === "Enter") {
@@ -88,24 +87,52 @@
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button on:click={() => select(0)}
><div><span class="icon key-hint">shift</span>+<span class="key-hint">ESC</span></div>
{$LL.actionSearch.DELETE()}</button
<button on:click={() => select(0)} use:action={{shortcut: "shift+esc"}}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{title: $LL.modal.CLOSE(), shortcut: "esc"}}
class="icon"
on:click={() => dispatch("close")}>close</button
>
<button title={$LL.modal.CLOSE()} class="icon" on:click={() => dispatch("close")}>close</button>
</div>
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
<fieldset class="filters">
<label
>{$LL.actionSearch.filter.ALL()}<input
checked
name="category"
type="radio"
value={undefined}
bind:group={filter}
/></label
>
{#each KEYMAP_CATEGORIES as category}
<label
>{category.name}<input
name="category"
type="radio"
value={new Set(Object.keys(category.actions).map(Number))}
bind:group={filter}
/></label
>
{/each}
</fieldset>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i
>Exact match&nbsp;<span class="icon key-hint">shift</span>+<span class="icon key-hint"
>keyboard_return</span
></i
>
<i>Exact match</i>
<ActionListItem id={exact} on:click={() => select(exact)} />
</li>
{/if}
@@ -116,7 +143,7 @@
<li>Action code is out of range</li>
{/if}
{/if}
{#each results as id (id)}
{#each filter ? results.filter(it => filter.has(it)) : results as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
</ul>
@@ -124,6 +151,32 @@
</dialog>
<style lang="scss">
.filters {
display: flex;
gap: 4px;
border: none;
label {
height: unset;
padding-block: 2px;
padding-inline: 4px;
font-size: 14px;
border: 1px solid currentcolor;
border-radius: 6px;
&:has(:checked) {
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
input {
display: none;
}
}
}
dialog {
display: flex;
align-items: center;
@@ -156,51 +209,16 @@
}
}
h2 {
margin-inline: 16px;
}
.search-row {
display: flex;
gap: 4px;
align-items: center;
margin-inline: 16px;
> button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: fit-content;
color: currentcolor;
background: none;
border: none;
border-radius: 100%;
&:not(.icon) {
font-family: inherit;
font-weight: bold;
}
& > div {
display: flex;
gap: 2px;
align-items: center;
}
&:last-child {
aspect-ratio: 1;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
}
}
.content {
position: relative;
transform-origin: top left;
overflow: hidden;
display: flex;
@@ -227,7 +245,7 @@
background: none;
border: none;
border-bottom: 1px solid var(--md-sys-color-primary-container);
border-bottom: 1px solid var(--md-sys-color-surface-variant);
transition: all 250ms ease;
@@ -281,26 +299,4 @@
border-radius: 0 0 8px 8px;
}
}
.key-hint {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
margin-block: 6px;
padding: 2px;
font-size: 14px;
font-weight: normal;
color: currentcolor;
border: 1px solid currentcolor;
border-radius: 4px;
&.icon {
padding: 0;
font-size: 18px;
}
}
</style>

View File

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

View File

@@ -1,18 +0,0 @@
<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>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import {getContext} from "svelte"
import type {Writable} from "svelte/store"
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout"
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {layout} from "$lib/undo-redo.js"
import {osLayout} from "$lib/os-layout.js"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import {action} from "$lib/title"
const {fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize} =
getContext<VisualLayoutConfig>("visual-layout-config")
const activeLayer = getContext<Writable<number>>("active-layer")
export let key: CompiledLayoutKey
export let fontSizeMultiplier = 1
export let middle: [number, number]
export let pos: [number, number]
export let rotate: number
export let positions: [[number, number], [number, number], [number, number]]
</script>
{#each positions as position, layer}
{@const {action: actionId, isApplied} = $layout[layer][key.id] ?? {action: 0, isApplied: true}}
{@const {code, icon, id, display, title, keyCode, variant} = KEYMAP_CODES[actionId] ?? {code: actionId}}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) +
(variant === "left" ? " (left)" : variant === "right" ? " (right)" : "")}
{@const isActive = layer === $activeLayer}
{@const direction = [
Math.sign(middle[0]) * (Math.abs(middle[0]) - margin * 3) * position[0],
Math.sign(middle[1]) * (Math.abs(middle[1]) - margin * 3) * position[1],
]}
{@const hasIcon = !dynamicMapping && !!icon}
<text
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"}
text-anchor="middle"
alignment-baseline="central"
x={pos[0] + middle[0] + (isApplied ? 0 : fontSize / 3)}
y={pos[1] + middle[1]}
font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
font-family={hasIcon ? "Material Symbols Rounded" : undefined}
opacity={isActive ? 1 : inactiveOpacity}
style:scale={isActive ? 1 : inactiveScale}
style:translate={isActive
? "0 0 0"
: `${direction[0].toPrecision(2)}px ${direction[1].toPrecision(2)}px 0`}
style:rotate="{rotate}deg"
use:action={{title: tooltip}}
>
{#if code !== 0}
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
{/if}
{#if !isApplied}
<tspan></tspan>
{/if}
</text>
{/each}
<style lang="scss">
$focus-transition: 10ms;
$transition: 200ms;
text {
will-change: translate, scale;
user-select: none;
transform-origin: center;
transform-box: fill-box;
transition:
fill #{$focus-transition} ease,
opacity #{$transition} ease,
translate #{$transition} ease,
scale #{$transition} ease;
}
text:focus-within {
outline: none;
}
</style>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
import {getContext} from "svelte"
import type {VisualLayoutConfig} from "./visual-layout.js"
import KeyText from "$lib/components/layout/KeyText.svelte"
const {scale, margin, strokeWidth} = getContext<VisualLayoutConfig>("visual-layout-config")
export let i: number
export let key: CompiledLayoutKey
$: posX = key.pos[0] * scale
$: posY = key.pos[1] * scale
$: sizeX = key.size[0] * scale
$: sizeY = key.size[1] * scale
</script>
<g class="key-group" on:click on:keypress on:focusin role="button" tabindex={i + 1}>
{#if key.shape === "square"}
<rect
x={posX + margin}
y={posY + margin}
rx={key.cornerRadius * scale}
width={sizeX - margin * 2}
height={sizeY - margin * 2}
stroke-width={strokeWidth}
/>
<KeyText
{key}
middle={[sizeX / 2, sizeY / 2]}
pos={[posX, posY]}
rotate={-key.rotate}
positions={[
[-1, 1],
[-1, -1],
[1, -1],
]}
/>
{:else if key.shape === "quarter-circle"}
{@const innerMargin = margin / 2}
{@const r1 = sizeX / 2 - margin}
{@const p1 = r1 - innerMargin}
{@const r2 = r1 - sizeY + innerMargin * 2}
{@const p2 = r2 - innerMargin}
{@const multiplier = 1.25}
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
{@const rotX = Math.round((Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
{@const rotY = Math.round((Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
{@const rc = r1 - (r1 - r2) / 2}
{@const middleX = Math.cos(rotateRad) * rc}
{@const middleY = Math.sin(rotateRad) * rc}
<path
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(p1 - p2)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
/>
<KeyText
{key}
middle={[middleX, middleY]}
pos={[posX, posY]}
rotate={0}
fontSizeMultiplier={multiplier}
positions={[
[-rotY, -rotX],
[-rotX, -rotY],
[rotX, rotY],
]}
/>
{/if}
</g>
<style lang="scss">
$focus-transition: 10ms;
$transition: 200ms;
rect {
transform-origin: center;
transform-box: fill-box;
}
path,
g {
transform-origin: top left;
transform-box: fill-box;
}
path,
rect {
fill: var(--md-sys-color-background);
fill-opacity: 0;
stroke: currentcolor;
}
path {
fill: currentcolor;
fill-opacity: 0;
stroke-opacity: 0.3;
}
g:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
}
g:focus-within {
color: var(--md-sys-color-primary);
outline: none;
> path,
> rect {
fill: currentcolor;
fill-opacity: 0.2;
}
}
</style>

View File

@@ -1,39 +1,58 @@
<script lang="ts">
import {serialPort} from "$lib/serial/connection"
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
import {action} from "$lib/title"
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
import {getContext} from "svelte"
import type {Writable} from "svelte/store"
import type {VisualLayout} from "$lib/serialization/visual-layout"
$: device = $serialPort?.device ?? "ONE"
let activeLayer = 0
const activeLayer = getContext<Writable<number>>("active-layer")
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const
const layouts = {
ONE: () => import("$lib/assets/layouts/one.yml").then(it => it.default as VisualLayout),
LITE: () => import("$lib/assets/layouts/lite.yml").then(it => it.default as VisualLayout),
X: () => import("$lib/assets/layouts/generic/103-key.yml").then(it => it.default as VisualLayout),
}
</script>
<div>
<div class="container">
<fieldset>
{#each layers as [title, icon, value]}
<button
{title}
class="icon"
on:click={() => (activeLayer = value)}
class:active={activeLayer === value}
use:action={{title, shortcut: `alt+${value + 1}`}}
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}
{#await layouts[device]() then visualLayout}
<GenericLayout {visualLayout} />
{/await}
</div>
<style lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-bottom: 96px;
}
fieldset {
position: relative;
@@ -41,7 +60,6 @@
align-items: center;
justify-content: center;
margin-block-end: -36px;
padding: 0;
border: none;
@@ -71,13 +89,19 @@
outline: 8px solid var(--md-sys-color-background);
}
&:first-child,
&:last-child {
aspect-ratio: unset;
height: unset;
}
&:first-child {
padding-inline-end: 16px;
padding-inline: 4px 16px;
border-radius: 16px 0 0 16px;
}
&:last-child {
padding-inline-start: 16px;
padding-inline: 16px 4px;
border-radius: 0 16px 16px 0;
}

View File

@@ -1,65 +0,0 @@
<script>
import RingInput from "$lib/components/layout/RingInput.svelte"
export let activeLayer = 0
</script>
<div class="col layout" style="gap: 0">
<div class="row" style="gap: 156px">
<div class="row">
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} />
<div class="col">
<RingInput {activeLayer} keys={{d: 25, e: 26, n: 27, w: 28, s: 29}} />
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} />
</div>
<div class="col">
<RingInput {activeLayer} keys={{d: 20, e: 21, n: 22, w: 23, s: 24}} />
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} />
</div>
<RingInput {activeLayer} keys={{d: 15, e: 16, n: 17, w: 18, s: 19}} />
</div>
<div class="row">
<RingInput {activeLayer} keys={{d: 60, w: 61, n: 62, e: 63, s: 64}} />
<div class="col">
<RingInput {activeLayer} keys={{d: 65, w: 66, n: 67, e: 68, s: 69}} />
<RingInput {activeLayer} keys={{d: 80, w: 81, n: 82, e: 83, s: 84}} />
</div>
<div class="col">
<RingInput {activeLayer} keys={{d: 70, w: 71, n: 72, e: 73, s: 74}} />
<RingInput {activeLayer} keys={{d: 85, w: 86, n: 87, e: 88, s: 89}} />
</div>
<RingInput {activeLayer} keys={{d: 75, w: 76, n: 77, e: 78, s: 79}} />
</div>
</div>
<div class="row" style="gap: 48px; margin-top: -32px">
<RingInput {activeLayer} keys={{d: 10, e: 11, n: 12, w: 13, s: 14}} />
<RingInput {activeLayer} keys={{d: 55, w: 56, n: 57, e: 58, s: 59}} />
</div>
<div class="row" style="gap: 160px">
<RingInput {activeLayer} keys={{d: 5, e: 6, n: 7, w: 8, s: 9}} />
<RingInput {activeLayer} keys={{d: 50, w: 51, n: 52, e: 53, s: 54}} />
</div>
<div class="row" style="gap: 320px; margin-top: -12px">
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} />
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} />
</div>
</div>
<style lang="scss">
.row,
.col {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
.row {
flex-direction: row;
}
.col {
flex-direction: column;
}
</style>

View File

@@ -1,184 +0,0 @@
<script lang="ts">
import {changes, highlightActions, layout} from "$lib/serial/connection"
import type {Change} 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" | "s" | "n" | "w" | "e", number>
const virtualLayerMap = [1, 0, 2]
const characterOffset = 8
function offsetDistance(quadrant: number, layer: number, activeLayer: number): number {
const layerOffsetIndex = virtualLayerMap[layer] - virtualLayerMap[activeLayer]
const layerOffset = quadrant > 2 ? -characterOffset : characterOffset
return 25 * quadrant + layerOffsetIndex * layerOffset
}
function getActions(id: number, layout: CharaLayout, changes: Change[]): [KeyInfo, KeyInfo | undefined][] {
return Array.from({length: 3}).map((_, i) => {
const actionId = layout?.[i][id]
const changedId = changes.findLast(it => it?.layout?.[i]?.[id] !== undefined)?.layout![i]![id]
if (changedId !== undefined) {
return [KEYMAP_CODES[changedId], KEYMAP_CODES[actionId]]
} else {
return [KEYMAP_CODES[actionId], undefined]
}
})
}
</script>
<div class="radial">
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
{@const actions = getActions(id, $layout, $changes)}
<button
use:editableLayout={{activeLayer, id}}
class:active={actions.some(([it]) => it && $highlightActions?.includes(it.code))}
>
{#each actions as [keyInfo, old], layer}
{#if keyInfo}
<span
class:active={virtualLayerMap[activeLayer] === virtualLayerMap[layer]}
class:icon={!!keyInfo.icon}
class:changed={!!old}
style="offset-distance: {offsetDistance(quadrant, layer, activeLayer)}%"
>{keyInfo.icon || keyInfo.id || keyInfo.code}</span
>
{/if}
{/each}
</button>
{/each}
</div>
<style lang="scss">
@use "sass:math";
$border-width: 18px;
$gap: 6px;
$size: 96;
$offset: 14;
$scale-difference: 0.2;
$transition-time: 750ms;
.radial {
position: relative;
container: radial / size;
width: #{$size * 1px};
height: #{$size * 1px};
transition: all 250ms ease;
}
span {
$cr: math.div($size, 2) - 2 * $offset;
will-change: scale, offset-distance;
user-select: none;
scale: 0.9;
offset-path: path(
"M#{math.div($size, 2)} #{$offset}A#{$cr} #{$cr} 0 1 1 #{math.div($size, 2)} #{$size - $offset}A#{$cr} #{$cr} 0 1 1 #{math.div($size, 2)} #{$offset}Z"
);
offset-rotate: 0deg;
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 16px;
opacity: 0.2;
transition:
scale $transition-time ease,
opacity $transition-time ease,
offset-distance $transition-time ease;
&.active {
scale: 1;
opacity: 1;
}
&.icon {
font-size: 20px;
font-weight: 800;
}
&.changed {
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
}
button {
cursor: pointer;
position: absolute;
display: grid;
width: 100cqw;
height: 100cqh;
padding: 0;
font-family: "Noto Sans Mono", monospace;
font-size: 16px;
font-weight: 900;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
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);
}
&:nth-child(1) {
clip-path: polygon(50% 50%, 0 0, 100% 0);
}
&:nth-child(2) {
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
}
&:nth-child(3) {
clip-path: polygon(50% 50%, 0 100%, 100% 100%);
}
&: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;
}
}
</style>

View File

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

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import {createEventDispatcher} from "svelte"
import Dialog from "$lib/dialogs/Dialog.svelte"
export let title: string
export let message: string | undefined
export let abortTitle: string
export let confirmTitle: string
const dispatch = createEventDispatcher()
</script>
<Dialog>
<h1>{@html title}</h1>
{#if message}
<p>{@html message}</p>
{/if}
<div class="buttons">
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}>{confirmTitle}</button>
</div>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import {onMount} from "svelte"
onMount(() => {
modal.showModal()
})
let modal: HTMLDialogElement
</script>
<dialog bind:this={modal}>
<slot />
</dialog>
<style lang="scss">
dialog {
min-width: 300px;
max-width: 512px;
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
border: none;
border-radius: 38px;
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
}
dialog::backdrop {
opacity: 0.5;
background: black;
}
</style>

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte"
import type {Change, ChordChange, LayoutChange, SettingChange} from "$lib/undo-redo"
import {ChangeType, chords} from "$lib/undo-redo"
import ActionString from "$lib/components/ActionString.svelte"
import LL from "../../i18n/i18n-svelte"
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
export let changes: Change[] = [
{type: ChangeType.Layout, layer: 0, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Setting, id: 0, setting: 2},
{type: ChangeType.Chord, id: [1], actions: [55], phrase: [55, 63, 37, 36]},
{
type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
phrase: [],
},
]
$: existingChords = new Set($chords.map(it => JSON.stringify(it.id)))
$: layoutChanges = Array.from(
{length: 3},
(_, i) => changes.filter(it => it.type === ChangeType.Layout && it.layer === i) as LayoutChange[],
)
$: settingChanges = changes.filter(it => it.type === ChangeType.Setting) as SettingChange[]
$: chordChanges = {
added: changes.filter(
it =>
it.type === ChangeType.Chord && it.phrase.length > 0 && !existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
changed: changes.filter(
it => it.type === ChangeType.Chord && it.phrase.length > 0 && existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
deleted: changes.filter(it => it.type === ChangeType.Chord && it.phrase.length === 0) as ChordChange[],
}
$: totalChordChanges = Object.values(chordChanges).reduce((acc, curr) => acc + curr.length, 0)
</script>
<Dialog>
<h1>{$LL.changes.TITLE()}</h1>
<h2>
<label><input type="checkbox" class="checkbox" />{$LL.changes.ALL_CHANGES()}</label>
</h2>
<ul>
{#if layoutChanges.some(it => it.length > 0)}
<li>
<h3>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.TITLE(layoutChanges.reduce((acc, curr) => acc + curr.length, 0))}
</label>
</h3>
<ul>
{#each layoutChanges
.map((it, i) => /** @type {const} */ ([it, i + 1]))
.filter(([it]) => it.length > 0) as [changes, layer]}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({changes: changes.length, layer})}
</label>
</h4>
</li>
{/each}
</ul>
</li>
{/if}
{#if settingChanges.length > 0}
<li>
<h3>
<label
><input type="checkbox" class="checkbox" />{$LL.changes.settings.TITLE(
settingChanges.length,
)}</label
>
</h3>
</li>
{/if}
{#if totalChordChanges > 0}
<li>
<h3>
<label
><input type="checkbox" class="checkbox" />{$LL.changes.chords.TITLE(totalChordChanges)}</label
>
</h3>
<ul>
{#each Object.entries(chordChanges) as [category, changes]}
{#if changes.length > 0}
<li>
<h4>
<label
><input type="checkbox" class="checkbox" />
{#if category === "added"}
{$LL.changes.chords.NEW_CHORDS(changes.length)}
{:else if category === "changed"}
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
{:else if category === "deleted"}
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
{/if}
</label>
</h4>
<ul>
{#each changes as change}
<li>
<label>
<input type="checkbox" class="checkbox" />
<ActionString display="keys" actions={change.actions} />
<ActionString actions={change.phrase} />
</label>
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
</li>
{/if}
</ul>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
}
h2 {
font-size: 1.5em;
}
ul {
padding-inline-start: 0;
list-style: none;
}
li {
margin-inline-start: 24px;
}
</style>

View File

@@ -0,0 +1,31 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte"
export async function askForConfirmation(
title: string,
message: string,
confirmTitle: string,
abortTitle: string,
): Promise<boolean> {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title,
message,
confirmTitle,
abortTitle,
},
})
let resolvePromise: (value: boolean) => void
const resultPromise = new Promise<boolean>(resolve => {
resolvePromise = resolve
})
dialog.$on("abort", () => resolvePromise(false))
dialog.$on("confirm", () => resolvePromise(true))
const result = await resultPromise
dialog.$destroy()
return result
}

View File

@@ -1,35 +0,0 @@
import type {Action} from "svelte/action"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {changes, layout} from "$lib/serial/connection"
import {get} from "svelte/store"
export const editableLayout: Action<HTMLButtonElement, {activeLayer: number; id: number}> = (
node,
{id, activeLayer},
) => {
let component: ActionSelector | undefined
function present() {
component?.$destroy()
component = new ActionSelector({
target: document.body,
props: {currentAction: get(layout)[activeLayer][id]},
})
component.$on("close", () => {
component!.$destroy()
})
component.$on("select", ({detail}) => {
changes.update(changes => {
changes.push({layout: {[activeLayer]: {[id]: detail}}})
return changes
})
component!.$destroy()
})
}
node.addEventListener("click", present)
return {
destroy() {
node.removeEventListener("click", present)
},
}
}

25
src/lib/os-layout.ts Normal file
View File

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

View File

@@ -1,5 +1,5 @@
import type {Action} from "svelte/action"
import {persistentWritable} from "$lib/storage"
import type { Action } from "svelte/action"
import { persistentWritable } from "$lib/storage"
export interface UserPreferences {
backup: boolean
@@ -13,7 +13,7 @@ export const theme = persistentWritable("user-theme", {
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", {
backup: false,
autoConnect: true,
autoConnect: false,
})
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => {

View File

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

View File

@@ -1,17 +1,39 @@
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
import {serialLog} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
import {SemVer} from "$lib/serial/sem-ver"
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
import {browser} from "$app/environment"
export const VENDOR_ID = 0x239a
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", {usbProductId: 32783, usbVendorId: 9114}],
["LITE S2", {usbProductId: 33070, usbVendorId: 12346}],
["LITE M0", {usbProductId: 32796, usbVendorId: 9114}],
["X", {usbProductId: 33163, usbVendorId: 12346}],
])
const KEY_COUNTS = {
ONE: 90,
LITE: 67,
X: 256,
} as const
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))
return navigator.serial.getPorts().then(ports =>
ports.filter(it => {
const {usbProductId, usbVendorId} = it.getInfo()
for (const filter of PORT_FILTERS.values()) {
if (filter.usbProductId === usbProductId && filter.usbVendorId === usbVendorId) {
return true
}
}
return false
}),
)
}
export async function canAutoConnect() {
@@ -29,31 +51,66 @@ export class CharaDevice {
private lock?: Promise<true>
version!: [number, number, number]
private readonly suspendDebounce = 100
private suspendDebounceId?: number
version!: SemVer
company!: "CHARACHORDER"
device!: "ONE" | "LITE"
device!: "ONE" | "LITE" | "X"
chipset!: "M0" | "S2"
keyCount!: 90 | 67 | 256
get portInfo() {
return this.port.getInfo()
}
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 it
})
try {
const ports = await getViablePorts()
this.port =
!manual && ports.length === 1
? ports[0]
: await navigator.serial.requestPort({filters: [...PORT_FILTERS.values()]})
await this.port.open({baudRate: this.baudRate})
const info = this.port.getInfo()
serialLog.update(it => {
it.push({
type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
16,
)}`,
})
return it
})
await this.port.close()
this.version = new SemVer(await this.send("VERSION").then(([version]) => version))
const [company, device, chipset] = await this.send("ID")
this.company = company as "CHARACHORDER"
this.device = device as "ONE" | "LITE" | "X"
this.chipset = chipset as "M0" | "S2"
this.keyCount = KEY_COUNTS[this.device]
} catch (e) {
alert(e)
console.error(e)
throw e
}
}
private async suspend() {
await this.reader.cancel()
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
}
private async wake() {
await this.port.open({baudRate: this.baudRate})
const decoderStream = new TextDecoderStream()
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal,
@@ -64,13 +121,6 @@ export class CharaDevice {
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() {
@@ -105,19 +155,9 @@ 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
*/
@@ -132,9 +172,23 @@ export class CharaDevice {
const exec = new Promise<T>(async resolve => {
let result!: T
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId)
} else {
await this.wake()
}
result = await callback(send, read)
} finally {
this.lock = undefined
delete this.lock
this.suspendDebounceId = setTimeout(() => {
// cannot be locked here as all the code until clearTimeout is sync
console.assert(this.lock === undefined)
this.lock = this.suspend().then(() => {
delete this.lock
delete this.suspendDebounceId
return true
})
}, this.suspendDebounce) as any
resolve(result)
}
})
@@ -174,7 +228,7 @@ export class CharaDevice {
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`)
return phrase === "0" ? undefined : parsePhrase(phrase)
return phrase === "2" ? undefined : parsePhrase(phrase)
}
async setChord(chord: Chord) {
@@ -184,12 +238,13 @@ export class CharaDevice {
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
)
if (status !== "0") throw new Error(`Failed with status ${status}`)
if (status !== "0") console.error(`Failed with status ${status}`)
}
async deleteChord(chord: Chord) {
async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
if (status.at(-1) !== "0") throw new Error(`Failed with status ${status}`)
console.log(status)
if (status.at(-1) !== "2") throw new Error(`Failed with status ${status}`)
}
/**
@@ -199,7 +254,8 @@ export class CharaDevice {
* @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}`)
const [status] = await this.send(`VAR B4 A${layer} ${id} ${action}`)
console.log(status)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
@@ -234,7 +290,7 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(`VAR B2 ${id} ${value}`)
const [status] = await this.send(`VAR B2 ${id.toString(16).toUpperCase()} ${value}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
@@ -242,8 +298,9 @@ export class CharaDevice {
* 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})`)
const [value, status] = await this.send(`VAR B1 ${id.toString(16).toUpperCase()}`)
if (status !== "0")
throw new Error(`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`)
return Number(value)
}
@@ -252,8 +309,6 @@ export class CharaDevice {
*/
async reboot() {
await this.send("RST")
await this.disconnect()
// TODO: reconnect
}
/**
@@ -261,8 +316,13 @@ export class CharaDevice {
*/
async bootloader() {
await this.send("RST BOOTLOADER")
await this.disconnect()
// TODO: more...
}
/**
* Resets the device
*/
async reset(type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC") {
await this.send(`RST ${type}`)
}
/**

View File

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

27
src/lib/serial/sem-ver.ts Normal file
View File

@@ -0,0 +1,27 @@
export class SemVer {
major: number
minor: number
patch: number
preRelease?: string
meta?: string
constructor(versionString: string) {
const [, major, minor, patch, preRelease, meta] =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
versionString,
)!
this.major = Number.parseInt(major)
this.minor = Number.parseInt(minor)
this.patch = Number.parseInt(patch)
if (preRelease) this.preRelease = preRelease
if (meta) this.meta = meta
}
toString() {
return (
`${this.major}.${this.minor}.${this.patch}` +
(this.preRelease ? `-${this.preRelease}` : "") +
(this.meta ? `+${this.meta}` : "")
)
}
}

12
src/lib/serial/updater.ts Normal file
View File

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

View File

@@ -24,7 +24,7 @@ export function decompressActions(raw: Uint8Array): number[] {
const actions: number[] = []
for (let i = 0; i < raw.length; i++) {
let action = raw[i]
if (action < 32) {
if (action > 0 && action < 32) {
action = (action << 8) | raw[++i]
}
actions.push(action)

View File

@@ -20,7 +20,7 @@ export async function toBase64(blob: Blob): Promise<string> {
})
}
export async function fromBase64(base64: string): Promise<Blob> {
export async function fromBase64(base64: string, fetch = window.fetch): Promise<Blob> {
return fetch(
`data:application/octet-stream;base64,${base64
.replaceAll(".", "+")

View File

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

View File

@@ -1,6 +1,5 @@
import type {Action} from "svelte/action"
import {serialPort, unsavedChanges} from "$lib/serial/connection"
import {get} from "svelte/store"
import {changes, ChangeType, settings} from "$lib/undo-redo"
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
node: HTMLInputElement,
@@ -8,16 +7,24 @@ export const setting: Action<HTMLInputElement, {id: number; inverse?: number; sc
) {
node.setAttribute("disabled", "")
const type = node.getAttribute("type") as "number" | "checkbox"
const min = node.hasAttribute("min") ? Number(node.getAttribute("min")) : undefined
const max = node.hasAttribute("max") ? Number(node.getAttribute("max")) : undefined
console.log(min, max, "|", id, "|", node.getAttribute("min"), node.getAttribute("max"))
const unsubscribe = serialPort.subscribe(async port => {
if (port) {
const unsubscribe = settings.subscribe(async settings => {
if (id in settings) {
const {value, isApplied} = settings[id]
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.checked = value !== 0
}
if (isApplied) {
node.classList.remove("pending-changes")
} else {
node.classList.add("pending-changes")
}
node.removeAttribute("disabled")
} else {
@@ -25,33 +32,35 @@ export const setting: Action<HTMLInputElement, {id: number; inverse?: number; sc
}
})
async function listener(event: Event) {
const currentValue = await get(serialPort)!.getSetting(id)
let value = 0
async function listener() {
let value: number
if (type === "number") {
value = Number((event as InputEvent).data)
value = Number(node.value)
if (Number.isNaN(value)) return
value = inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : value
value = Math.floor(
inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : value,
)
if (min !== undefined) value = Math.max(min, value)
if (max !== undefined) value = Math.min(max, value)
} else {
value = node.checked ? 1 : 0
}
await get(serialPort)!.setSetting(id, value)
const originalValue = get(unsavedChanges).get(id)
unsavedChanges.update(it => {
if (originalValue === value) {
it.delete(id)
} else if (!it.has(id)) {
it.set(id, currentValue)
}
return it
changes.update(changes => {
changes.push({
type: ChangeType.Setting,
id: id,
setting: value,
})
return changes
})
}
node.addEventListener("input", listener)
node.addEventListener("change", listener)
return {
destroy() {
node.removeEventListener("input", listener)
node.removeEventListener("change", listener)
unsubscribe()
},
}

View File

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

View File

@@ -1,5 +1,5 @@
import {compressActions, decompressActions} from "$lib/serialization/actions"
import {CHARA_FILE_TYPES} from "$lib/share/share-url"
import {compressActions, decompressActions} from "../serialization/actions"
import {CHARA_FILE_TYPES} from "../share/share-url"
export type ActionArray = number[] | ActionArray[]
export function serializeActionArray(array: ActionArray): Uint8Array {
@@ -11,7 +11,9 @@ export function serializeActionArray(array: ActionArray): Uint8Array {
return out
} else if (typeof array[0] === "number") {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number"))
return concatUint8Arrays(out, compressActions(array as number[]))
const compressed = compressActions(array as number[])
writer.setUint32(0, compressed.length)
return concatUint8Arrays(out, compressed)
} else if (Array.isArray(array[0])) {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"))
return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray))
@@ -20,20 +22,21 @@ export function serializeActionArray(array: ActionArray): Uint8Array {
}
}
export function deserializeActionArray(raw: Uint8Array): ActionArray {
export function deserializeActionArray(raw: Uint8Array, cursor = {pos: 0}): ActionArray {
const reader = new DataView(raw.buffer)
const length = reader.getUint32(0)
const type = CHARA_FILE_TYPES[reader.getUint8(4)]
const length = reader.getUint32(cursor.pos)
cursor.pos += 4
const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)]
cursor.pos++
if (type === "number") {
return decompressActions(raw.slice(5, 5 + length))
const decompressed = decompressActions(raw.slice(cursor.pos, cursor.pos + length))
cursor.pos += length
return decompressed
} else if (type === "array") {
const innerLength = reader.getUint32(5)
const out = []
let cursor = 5
for (let i = 0; i < length; i++) {
out.push(deserializeActionArray(raw.slice(cursor, cursor + innerLength)))
cursor += innerLength
out.push(deserializeActionArray(raw, cursor))
}
return out
} else {

View File

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

View File

@@ -1,7 +1,7 @@
import type {CharaFile, CharaFiles} from "$lib/share/chara-file"
import type {ActionArray} from "$lib/share/action-array"
import {deserializeActionArray, serializeActionArray} from "$lib/share/action-array"
import {fromBase64, toBase64} from "$lib/serialization/base64"
import type {CharaFile, CharaFiles} from "../share/chara-file"
import type {ActionArray} from "../share/action-array"
import {deserializeActionArray, serializeActionArray} from "../share/action-array"
import {fromBase64, toBase64} from "../serialization/base64"
type CharaLayoutOrder = {
[K in CharaFiles["type"]]: Array<
@@ -15,6 +15,7 @@ const keys: CharaLayoutOrder = {
["device", "string"],
],
chords: [["chords", "array"]],
settings: [["settings", "array"]],
}
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const
@@ -42,17 +43,21 @@ export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Pr
return url
}
export async function charaFileFromUriComponent<T extends CharaFiles>(uriComponent: string): Promise<T> {
export async function charaFileFromUriComponent<T extends CharaFiles>(
uriComponent: string,
fetch = window.fetch,
): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep)
const file: any = {type: fileType, version: Number(version)}
const file: any = {type: fileType, charaVersion: Number(version)}
for (const [key, type] of keys[fileType as keyof typeof keys]) {
const value = values.pop()!
const value = values.shift()!
if (type === "string") {
file[key] = value
} else if (type === "array") {
const stream = (await fromBase64(value)).stream().pipeThrough(new DecompressionStream("deflate"))
const stream = (await fromBase64(value, fetch)).stream().pipeThrough(new DecompressionStream("deflate"))
const actions = new Uint8Array(await new Response(stream).arrayBuffer())
console.log(actions)
file[key] = deserializeActionArray(actions)
}
}

35
src/lib/style/_kbd.scss Normal file
View File

@@ -0,0 +1,35 @@
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
margin-block: 6px;
padding: 4px;
font-size: 14px;
font-weight: normal;
color: currentcolor;
border: 1px solid currentcolor;
border-radius: 4px;
&.icon {
padding: 2px;
font-size: 18px;
}
&:has(> kbd) {
gap: 4px;
padding: 0;
border: none;
}
> kbd {
padding: 2px;
&.icon {
padding: 0;
}
}
}

View File

@@ -0,0 +1,75 @@
a {
text-decoration: none;
}
a,
label:has(input),
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: inherit;
font-weight: 600;
color: currentcolor;
background: transparent;
border: none;
border-radius: 32px;
transition: all 250ms ease;
&.icon {
display: inline-flex;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
font-size: 24px;
border-radius: 50%;
}
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
&.compact {
height: 32px;
}
}
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;
}
}
.disabled,
:disabled {
pointer-events: none;
opacity: 0.5;
}

16
src/lib/style/print.scss Normal file
View File

@@ -0,0 +1,16 @@
@media print {
.print {
visibility: visible;
}
nav {
display: none;
}
body {
--md-sys-color-background: white !important;
--md-sys-color-on-background: black !important;
visibility: hidden;
}
}

10
src/lib/style/theme.scss Normal file
View File

@@ -0,0 +1,10 @@
@import "./form/button";
@import "./form/toggle";
@import "./form/checkbox";
@import "./kbd";
@import "./print";
* {
box-sizing: border-box;
appearance: none;
}

View File

@@ -24,6 +24,13 @@ $padding: 16px;
}
}
.tippy-box[data-theme~="tooltip"] {
color: var(--md-sys-color-on-background);
background-color: var(--md-sys-color-background);
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
}
.tippy-box[data-theme~="search-completion"] {
overflow: hidden;
filter: none;

45
src/lib/title.ts Normal file
View File

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

147
src/lib/undo-redo.ts Normal file
View File

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

1
src/lib/versioning.ts Normal file
View File

@@ -0,0 +1 @@
// TODO

View File

@@ -3,30 +3,36 @@
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 "$lib/style/theme.scss"
import {onDestroy, onMount} from "svelte"
import {applyTheme, argbFromHex, themeFromSourceColor} from "@material/material-color-utilities"
import Navigation from "./Navigation.svelte"
import {canAutoConnect} from "$lib/serial/device"
import {initSerial} from "$lib/serial/connection"
import type {LayoutServerData} from "./$types"
import type {LayoutData} from "./$types"
import {browser} from "$app/environment"
import BrowserWarning from "./BrowserWarning.svelte"
import "tippy.js/animations/shift-away.css"
import "tippy.js/dist/tippy.css"
import tippy from "tippy.js"
import {theme, userPreferences} from "$lib/preferences.js"
import {setLocale} from "../i18n/i18n-svelte"
import {LL, setLocale} from "../i18n/i18n-svelte"
import {loadLocale} from "../i18n/i18n-util.sync"
import {detectLocale} from "../i18n/i18n-util"
import type {Locales} from "../i18n/i18n-types"
import Footer from "./Footer.svelte"
import {runLayoutDetection} from "$lib/os-layout.js"
import PageTransition from "./PageTransition.svelte"
import {restoreFromFile} from "$lib/backup/backup"
import {goto} from "$app/navigation"
const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale()
loadLocale(locale)
setLocale(locale)
let stopLayoutDetection: () => void
if (browser) {
stopLayoutDetection = runLayoutDetection()
tippy.setDefaultProps({
animation: "shift-away",
theme: "surface-variant",
@@ -37,7 +43,7 @@
})
}
export let data: LayoutServerData
export let data: LayoutData
onMount(async () => {
theme.subscribe(it => {
@@ -47,10 +53,22 @@
})
if (import.meta.env.TAURI_FAMILY === undefined) {
const {initPwa} = await import("./pwa-setup")
await initPwa()
webManifestLink = await initPwa()
}
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) await initSerial()
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial()
}
if (data.importFile) {
restoreFromFile(data.importFile)
const url = new URL(location.href)
url.searchParams.delete("import")
await goto(url.href, {replaceState: true})
}
})
onDestroy(() => {
stopLayoutDetection?.()
})
let webManifestLink = ""
@@ -58,16 +76,18 @@
<svelte:head>
{@html webManifestLink}
<title>amaCC1ng</title>
<meta name="description" content="Tool for CharaChorder devices" />
<title>{$LL.TITLE()}</title>
<meta name="description" content={$LL.DESCRIPTION()} />
<meta name="theme-color" content={data.themeColor} />
</svelte:head>
<Navigation />
<main>
<!-- <PickChangesDialog /> -->
<PageTransition>
<slot />
</main>
</PageTransition>
<Footer />
@@ -76,34 +96,6 @@
{/if}
<style lang="scss" global>
* {
box-sizing: border-box;
appearance: none;
}
a {
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 {
overflow: hidden;
display: flex;
@@ -125,8 +117,7 @@
flex-direction: column;
flex-grow: 1;
align-items: center;
padding: 16px;
padding-inline: 16px;
}
h1 {

View File

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

View File

@@ -1,12 +0,0 @@
<script>
</script>
<svelte:head>
<title>dot i/o</title>
</svelte:head>
<h1>dot i/o V2</h1>
<section>
<h2>Layout</h2>
</section>

View File

@@ -1,45 +1,14 @@
<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
}
}
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup"
</script>
<section>
@@ -47,9 +16,24 @@
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend>
<button on:click={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button on:click={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button on:click={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save">
<button class="primary" on:click={downloadBackup}
><span class="icon">save</span>{$LL.backup.DOWNLOAD()}</button
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input on:input={restoreBackup} type="file" /><span class="icon">settings_backup_restore</span
@@ -72,6 +56,13 @@
}
}
fieldset {
display: flex;
margin-block: 16px;
border: 1px solid currentcolor;
border-radius: 16px;
}
section {
display: flex;
flex-direction: column;
@@ -95,34 +86,4 @@
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>

View File

@@ -17,9 +17,7 @@
>{$LL.browserWarning.INFO_BROWSER_SUFFIX()}
</p>
<div>
<a href="https://github.com/Theaninova/dotio/releases" target="_blank"
>{$LL.browserWarning.DOWNLOAD_APP()}</a
>
<p>{$LL.browserWarning.DOWNLOAD_APP()}</p>
</div>
</dialog>
@@ -50,9 +48,10 @@
a {
color: var(--md-sys-color-on-error);
text-decoration: underline;
}
div > a {
div > p {
display: flex;
gap: 8px;
align-items: center;

View File

@@ -1,5 +1,6 @@
<script>
import {page} from "$app/stores"
import {action} from "$lib/title"
import LL from "../i18n/i18n-svelte"
$: paths = [
@@ -10,8 +11,8 @@
</script>
<nav>
{#each paths as { href, title, icon }}
<a {href} class:active={$page.url.pathname.startsWith(href)}>
{#each paths as { href, title, icon }, i}
<a {href} class:active={$page.url.pathname.startsWith(href)} use:action={{shortcut: `shift+${i + 1}`}}>
<span class="icon">{icon}</span>
{title}
</a>
@@ -27,30 +28,13 @@
padding: 8px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
border-radius: 32px;
}
a {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
margin: 0;
padding: 8px;
padding-inline: 16px;
font-weight: 600;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
border-radius: 24px;
transition: all 250ms ease;
}
a.active {
--icon-fill: 1;

View File

@@ -5,8 +5,36 @@
import {preference} from "$lib/preferences"
import LL from "../i18n/i18n-svelte"
function reboot() {
$serialPort?.reboot()
$serialPort = undefined
powerDialog = false
setTimeout(() => {
initSerial()
}, 1000)
}
function bootloader() {
$serialPort?.bootloader()
$serialPort = undefined
rebootInfo = true
powerDialog = false
}
async function updateFirmware() {
const {usbVendorId: vendorId, usbProductId: productId} = $serialPort!.portInfo
$serialPort!.bootloader()
await new Promise(resolve => setTimeout(resolve, 1000))
console.log(await navigator.usb.requestDevice({filters: [{vendorId, productId}]}))
}
let rebootInfo = false
let terminal = false
let powerDialog = false
$: if ($serialPort) {
rebootInfo = false
}
</script>
<section>
@@ -21,11 +49,45 @@
{$serialPort.device}
{$serialPort.chipset}
<br />
Version {$serialPort.version.map(it => it.toString()).join(".")}
Version {$serialPort.version}
</p>
<!--<button on:click={updateFirmware}>Update</button>-->
{/if}
{#if browser}
{#if navigator.userAgent.includes("Linux") && !$serialPort}
<details class="linux-info" transition:slide>
<summary>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</summary>
In most cases you can simply follow the
<a target="_blank" href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
>
on serial port permissions.
<p>Special systems:</p>
<ul>
<li>
<a target="_blank" href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
>Arch and Arch-based like Manjaro or EndeavourOS</a
>
</li>
<li>
<a
target="_blank"
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
>NixOS</a
>
</li>
<li>
<a target="_blank" href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
>Gentoo</a
>
</li>
</ul>
</details>
{/if}
{#if rebootInfo}
<p transition:slide><b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b></p>
{/if}
<div class="row">
{#if $serialPort}
<button
@@ -57,20 +119,23 @@
</div>
</div>
{#if powerDialog}
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
<div
class="backdrop"
role="button"
tabindex="-1"
transition:fade={{duration: 250}}
on:click={() => (powerDialog = !powerDialog)}
on:keypress={event => {
if (event.key === "Enter") 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={reboot}
><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
<button on:click={bootloader}
><span class="icon">rule_settings</span>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
>
</dialog>
{/if}
@@ -86,13 +151,28 @@
margin-block: 8px;
}
details a {
display: inline;
padding-inline: 0;
text-decoration: underline;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
min-width: 260px;
width: 300px;
}
summary {
cursor: pointer;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
}
.backdrop {
@@ -153,43 +233,6 @@
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);

View File

@@ -1,12 +1,27 @@
<script lang="ts">
import LL from "../i18n/i18n-svelte"
import {changes} from "$lib/serial/connection"
import type {Change} from "$lib/serial/connection"
import {changes, ChangeType, chords, layout, overlay, settings} from "$lib/undo-redo"
import type {Change} from "$lib/undo-redo"
import {fly} from "svelte/transition"
import {action} from "$lib/title"
import {
deviceChords,
deviceLayout,
deviceSettings,
serialPort,
syncProgress,
syncStatus,
} from "$lib/serial/connection"
import {askForConfirmation} from "$lib/dialogs/confirm-dialog"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
function undo() {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
function undo(event: MouseEvent) {
if (event.shiftKey) {
changes.set([])
} else {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
}
}
function redo() {
@@ -19,67 +34,131 @@
}
let redoQueue: Change[] = []
function apply() {
// TODO
async function save() {
try {
const port = $serialPort
if (!port) return
$syncStatus = "uploading"
for (const [id, {actions, phrase, deleted}] of $overlay.chords) {
if (!deleted) {
if (id !== JSON.stringify(actions)) {
const existingChord = await port.getChordPhrase(actions)
if (
existingChord !== undefined &&
!(await askForConfirmation(
$LL.configure.chords.conflict.TITLE(),
$LL.configure.chords.conflict.DESCRIPTION(
actions.map(it => `<kbd>${KEYMAP_CODES[it].id}</kbd>`).join(" "),
),
$LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(),
))
) {
changes.update(changes =>
changes.filter(it => !(it.type === ChangeType.Chord && JSON.stringify(it.id) === id)),
)
continue
}
await port.deleteChord({actions: JSON.parse(id)})
}
await port.setChord({actions, phrase})
} else {
await port.deleteChord({actions})
}
}
for (const [layer, actions] of $overlay.layout.entries()) {
for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action)
}
}
for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting)
}
// Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight,
// aka make it more "energy intensive" to click.
// The only conceivable way users could reach the commit limit in this case
// would be if they click it every time they change a setting.
// Because of that, we don't need to show a fearmongering message such as
// "Your device will break after you click this 10,000 times!"
const virtualWriteTime = 1000
const startStamp = performance.now()
await new Promise<void>(resolve => {
function animate() {
const delta = performance.now() - startStamp
syncProgress.set({
max: virtualWriteTime,
current: delta,
})
if (delta >= virtualWriteTime) {
resolve()
} else {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
})
await port.commit()
$deviceLayout = $layout.map(layer => layer.map<number>(({action}) => action)) as [
number[],
number[],
number[],
]
$deviceChords = $chords.filter(({deleted}) => !deleted).map(({actions, phrase}) => ({actions, phrase}))
$deviceSettings = $settings.map(({value}) => value)
$changes = []
} catch (e) {
alert(e)
console.error(e)
} finally {
$syncStatus = "done"
}
}
</script>
<button title={$LL.saveActions.UNDO()} class="icon" disabled={$changes.length === 0} on:click={undo}
>undo</button
<button
use:action={{title: $LL.saveActions.UNDO(), shortcut: "ctrl+z"}}
class="icon"
disabled={$changes.length === 0}
on:click={undo}>undo</button
>
<button title={$LL.saveActions.REDO()} class="icon" disabled={redoQueue.length === 0} on:click={redo}
>redo</button
<button
use:action={{title: $LL.saveActions.REDO(), shortcut: "ctrl+y"}}
class="icon"
disabled={redoQueue.length === 0}
on:click={redo}>redo</button
>
<div class="separator" />
<button title={$LL.saveActions.SAVE()} class="icon">save</button>
{#if $changes.length !== 0}
<button class="click-me" transition:fly={{x: 8}}
><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
<button
transition:fly={{x: 10}}
use:action={{title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s"}}
on:click={save}
class="click-me"><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
>
{/if}
<style lang="scss">
button {
cursor: pointer;
padding: 0;
color: currentcolor;
background: none;
border: none;
transition: all 250ms ease;
}
:disabled {
pointer-events: none;
opacity: 0.5;
}
.click-me {
display: flex;
align-items: center;
justify-content: center;
height: fit-content;
margin-inline: 8px;
padding-block: 2px;
padding-inline-start: 4px;
padding-inline-end: 8px;
padding-inline-start: 8px;
padding-inline-end: 12px;
font-family: inherit;
font-weight: bold;
color: var(--md-sys-color-primary);
border: 2px solid var(--md-sys-color-primary);
border-radius: 18px;
outline: 2px dashed var(--md-sys-color-primary);
outline-offset: 2px;
}
.separator {
width: 1px;
height: 24px;
background: var(--md-sys-color-outline-variant);
}
</style>

View File

@@ -1,32 +1,180 @@
<script>
import {version} from "$app/environment"
<script lang="ts">
import {browser, version} from "$app/environment"
import {action} from "$lib/title"
import LL, {setLocale} from "../i18n/i18n-svelte"
import {theme} from "$lib/preferences.js"
import type {Locales} from "../i18n/i18n-types"
import {detectLocale, locales} from "../i18n/i18n-util"
import {loadLocaleAsync} from "../i18n/i18n-util.async"
import {tick} from "svelte"
import SyncOverlay from "./SyncOverlay.svelte"
import {serialPort} from "$lib/serial/connection"
let locale = (browser && (localStorage.getItem("locale") as Locales)) || detectLocale()
$: if (browser)
(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
}
}
let languageSelect: HTMLSelectElement
</script>
<footer>
<ul>
<li>
<a href={HOMEPAGE_URL} rel="noreferrer" target="_blank"><span class="icon">commit</span> v{version}</a>
<!-- svelte-ignore not-defined -->
<a href={import.meta.env.VITE_HOMEPAGE_URL} rel="noreferrer" target="_blank"
><span class="icon">commit</span> v{version}</a
>
</li>
<li>
<a href={BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> File an issue</a
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Issues</a
>
</li>
<li>
<a href={import.meta.env.VITE_DOCS_URL} rel="noreferrer" target="_blank"
><span class="icon">description</span> Docs</a
>
</li>
<li>
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
>
</li>
</ul>
<div>
{#if !$serialPort}
<div class="warning">
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
</div>
{/if}
<SyncOverlay />
</div>
<ul>
<li>
<input use:action={{title: $LL.profile.theme.COLOR_SCHEME()}} type="color" bind:value={$theme.color} />
</li>
<li>
{#if $theme.mode === "light"}
<button use:action={{title: $LL.profile.theme.DARK_MODE()}} class="icon" on:click={switchTheme}>
dark_mode
</button>
{:else if $theme.mode === "dark"}
<button use:action={{title: $LL.profile.theme.LIGHT_MODE()}} class="icon" on:click={switchTheme}>
light_mode
</button>
{/if}
</li>
<li>
<button
class="icon"
use:action={{title: $LL.profile.LANGUAGE()}}
on:click={() => languageSelect.click()}
>translate
<select bind:value={locale} bind:this={languageSelect}>
{#each locales as code}
<option value={code}>{code}</option>
{/each}
</select>
</button>
</li>
</ul>
</footer>
<style>
footer {
<style lang="scss">
select {
position: absolute;
bottom: 0;
left: 0;
opacity: 0;
}
.warning {
color: var(--md-sys-color-error);
gap: 8px;
display: flex;
align-items: center;
justify-content: center;
}
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
inline-size: 20px;
block-size: 20px;
margin: 0;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
}
}
footer {
display: grid;
align-items: center;
justify-content: center;
grid-template-columns: 1fr auto 1fr;
width: 100%;
padding: 8px;
padding-inline-end: 16px;
padding-block-start: 0;
opacity: 0.4;
}
ul {
display: flex;
gap: 16px;
gap: 8px;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
&:last-child {
justify-content: flex-end;
}
}
ul:last-child {
gap: 12px;
button {
height: 24px;
font-size: 20px;
}
}
a {
@@ -36,6 +184,8 @@
font-size: 12px;
text-decoration: none;
padding-inline: 12px;
}
.icon {

View File

@@ -1,39 +1,23 @@
<script lang="ts">
import {serialPort, syncStatus, unsavedChanges} from "$lib/serial/connection"
import {serialPort, syncStatus} from "$lib/serial/connection"
import {slide, fly} from "svelte/transition"
import {canShare, triggerShare} from "$lib/share"
import {popup} from "$lib/popup"
import BackupPopup from "./BackupPopup.svelte"
import ConnectionPopup from "./ConnectionPopup.svelte"
import {canAutoConnect} from "$lib/serial/device"
import {browser} from "$app/environment"
import {userPreferences} from "$lib/preferences"
import {action} from "$lib/title"
import LL from "../i18n/i18n-svelte"
import Profile from "./Profile.svelte"
import ConfigTabs from "./ConfigTabs.svelte"
import EditActions from "./EditActions.svelte"
import {onMount} from "svelte"
async function flashChanges() {
$syncStatus = "uploading"
// Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight,
// aka make it more "energy intensive" to click.
// The only conceivable way users could reach the commit limit in this case
// would be if they click it every time they change a setting.
// Because of that, we don't need to show a fearmongering message such as
// "Your device will break after you click this 10,000 times!"
await new Promise(resolve => setTimeout(resolve, 6000))
$serialPort.commit()
unsavedChanges.update(it => {
it.clear()
return it
})
$syncStatus = "done"
}
$: if (browser && !canAutoConnect()) {
connectButton?.click()
}
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click()
}
})
let connectButton: HTMLButtonElement
</script>
@@ -47,7 +31,18 @@
<div class="actions">
{#if $canShare}
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
<button
use:action={{title: $LL.share.TITLE()}}
transition:fly={{x: -8}}
class="icon"
on:click={triggerShare}>share</button
>
<button
use:action={{title: $LL.print.TITLE()}}
transition:fly={{x: -8}}
class="icon"
on:click={() => print()}>print</button
>
<div transition:slide class="separator" />
{/if}
{#if import.meta.env.TAURI_FAMILY === undefined}
@@ -55,109 +50,36 @@
<PwaStatus />
{/await}
{/if}
{#if $unsavedChanges.size > 0}
<button
disabled={$syncStatus === "uploading"}
on:click={flashChanges}
transition:fly={{x: -8}}
title={$LL.deviceManager.APPLY_SETTINGS()}
class="icon"
>save
</button>
<div transition:slide class="separator" />
{/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 use:action={{title: $LL.backup.TITLE()}} use:popup={BackupPopup} class="icon {$syncStatus}">
{#if $userPreferences.backup}
history
{:else}
history_toggle_off
{/if}
</button>
<button
bind:this={connectButton}
title="Devices"
use:action={{title: $LL.deviceManager.TITLE()}}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
<button title={$LL.profile.TITLE()} use:popup={Profile} class="icon account">person</button>
</div>
</nav>
<style lang="scss">
@keyframes sync {
0% {
scale: 1 1;
opacity: 1;
}
85% {
scale: 1 0;
opacity: 1;
}
86% {
scale: 1 1;
opacity: 0;
}
100% {
scale: 1 1;
opacity: 1;
}
}
.uploading::after,
.downloading::after {
content: "";
position: absolute;
top: 12px;
left: 50%;
transform-origin: top;
translate: -50% 0;
width: 8px;
height: 10px;
background: var(--md-sys-color-background);
animation: sync 1s linear infinite;
}
.uploading::after {
transform-origin: bottom;
}
.downloading.active::after,
.uploading.active::after {
background: var(--md-sys-color-primary);
}
.sync.downloading::after {
top: 10px;
transform-origin: bottom;
border-radius: 4px;
}
.separator {
width: 1px;
height: 24px;
margin-inline: 4px;
background: var(--md-sys-color-outline-variant);
}
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 4px;
width: calc(min(100%, 28cm));
margin-block: 8px;
@@ -187,7 +109,7 @@
justify-content: center;
aspect-ratio: 1;
padding: 2px;
padding: 0;
color: inherit;
text-decoration: none;
@@ -206,7 +128,6 @@
.actions {
display: flex;
gap: 8px;
align-items: center;
&:last-child {
@@ -214,12 +135,6 @@
}
}
.icon.account {
font-size: 32px;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
:disabled {
pointer-events: none;
opacity: 0.5;

View File

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

View File

@@ -1,116 +0,0 @@
<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>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import {serialPort, syncProgress, syncStatus, sync} from "$lib/serial/connection"
import LL from "../i18n/i18n-svelte"
import {slide} from "svelte/transition"
</script>
<div class="container">
{#if $syncStatus !== "done"}
<div transition:slide>
<progress max={$syncProgress?.max ?? 1} value={$syncProgress?.current ?? 1}></progress>
{#if $syncStatus === "downloading"}
<div>{$LL.sync.TITLE_READ()}</div>
{:else}
<div>{$LL.sync.TITLE_WRITE()}</div>
{/if}
</div>
{:else if $serialPort}
<button transition:slide on:click={sync}><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button>
{/if}
</div>
<style lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
div {
font-size: 12px;
}
progress {
overflow: hidden;
width: 100%;
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
background: var(--md-sys-color-background);
}
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -0,0 +1,6 @@
{
"CHARACHORDER ONE M0": {
"latest": "1.1.3",
"next": null
}
}

View File

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

View File

@@ -2,5 +2,4 @@
import LL from "../../i18n/i18n-svelte"
</script>
<h4>{$LL.share.URL_COPIED()}</h4>
<button>{$LL.share.EXTRA_DOWNLOAD()}</button>
{$LL.share.URL_COPIED()}

View File

@@ -1,76 +1,175 @@
<script lang="ts">
import {chords} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import type {Chord} from "$lib/serial/chord"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
import {onDestroy, onMount, setContext} from "svelte"
import {changes, ChangeType, chords} from "$lib/undo-redo"
import type {ChordInfo} from "$lib/undo-redo"
import {derived, writable} from "svelte/store"
import ChordEdit from "./ChordEdit.svelte"
import {crossfade} from "svelte/transition"
import ChordActionEdit from "./ChordActionEdit.svelte"
const resultSize = 38
let results: HTMLElement
const pageSize = writable(0)
let resizeObserver: ResizeObserver
onMount(() => {
resizeObserver = new ResizeObserver(() => {
pageSize.set(Math.floor(results.clientHeight / resultSize))
})
pageSize.set(Math.floor(results.clientHeight / resultSize))
resizeObserver.observe(results)
})
onDestroy(() => {
resizeObserver?.disconnect()
})
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
function buildIndex(chords: Chord[]): Index {
function buildIndex(chords: ChordInfo[]): Index {
const index = new Index({tokenize: "full"})
chords.forEach((chord, i) => {
index.add(i, chord.phrase.map(it => KEYMAP_CODES[it].id).join(""))
if ("phrase" in chord) {
index.add(
i,
chord.phrase
.map(it => KEYMAP_CODES[it]?.id)
.filter(it => !!it)
.join(""),
)
}
})
return index
}
let searchFilter: number[] | undefined
const searchFilter = writable<number[] | undefined>(undefined)
function search(event: Event) {
const query = (event.target as HTMLInputElement).value
searchFilter = query && searchIndex ? searchIndex.search(query) : undefined
searchFilter.set(query && searchIndex ? searchIndex.search(query) : undefined)
page = 0
}
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
function insertChord(actions: number[]) {
const id = JSON.stringify(actions)
if ($chords.some(it => JSON.stringify(it.actions) === id)) {
alert($LL.configure.chords.DUPLICATE())
return
}
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
id: actions,
actions,
phrase: [],
})
return changes
})
}
const items = derived(
[searchFilter, chords],
([filter, chords]) =>
filter?.map(it => [chords[it], it] as const) ?? chords.map((it, i) => [it, i] as const),
)
const lastPage = derived(
[items, pageSize],
([items, pageSize]) => Math.ceil((items.length + 1) / pageSize) - 1,
)
setContext("cursor-crossfade", crossfade({}))
let page = 0
</script>
<svelte:head>
<title>Chord Manager</title>
<title>Chord Manager - CharaChorder Device Manager</title>
<meta name="description" content="Manage your chords" />
</svelte:head>
<div>
<div class="search-container">
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
on:input={search}
/>
<div class="paginator">
{#if $lastPage !== -1}
{page + 1} / {$lastPage + 1}
{:else}
- / -
{/if}
</div>
<button class="icon" on:click={() => (page = Math.max(page - 1, 0))} use:action={{shortcut: "ctrl+left"}}
>navigate_before</button
>
<button
class="icon"
on:click={() => (page = Math.min(page + 1, $lastPage))}
use:action={{shortcut: "ctrl+right"}}>navigate_next</button
>
</div>
<!--
{#if searchIndex}
<input
on:input={search}
type="search"
/>
{/if}-->
<section>
<section bind:this={results}>
<table>
{#each items.slice(0, 50) as [{ phrase, actions }, i]}
<tr style="view-transition-name: chord-{i}">
<th>
{#each phrase as char}
{KEYMAP_CODES[char].id}
{/each}
</th>
<td>
{#each actions as action}
{@const keyInfo = KEYMAP_CODES[action]}
{#if keyInfo}
<abbr title={keyInfo.title} class:icon={!!keyInfo.icon}>{keyInfo.icon || keyInfo.id}</abbr>
{:else}
<pre>{action}</pre>
{/if}
{/each}
</td>
</tr>
{/each}
{#if page === 0}
<tr
><th class="new-chord"><ChordActionEdit on:submit={({detail}) => insertChord(detail)} /></th><td /><td
/></tr
>
{/if}
{#if $lastPage !== -1}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord.id))}
<tr>
<ChordEdit {chord} />
</tr>
{/each}
{:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if}
</table>
<textarea placeholder={$LL.configure.chords.TRY_TYPING()}></textarea>
</section>
<style lang="scss">
.search-container {
display: flex;
align-items: center;
justify-content: center;
}
.paginator {
display: flex;
justify-content: flex-end;
min-width: 8ch;
}
.new-chord :global(.add) {
visibility: hidden;
}
textarea {
transition: border-color 250ms ease;
background: none;
color: inherit;
border: 1px dashed var(--md-sys-color-surface-variant);
padding: 8px;
border-radius: 4px;
&:focus {
outline: none;
border-color: var(--md-sys-color-primary);
}
}
caption {
margin-top: 156px;
}
input[type="search"] {
width: 512px;
margin-block-start: 16px;
@@ -98,55 +197,21 @@
}
section {
--scrollbar-color: var(--md-sys-color-surface-variant);
scrollbar-gutter: stable;
position: relative;
overflow-x: hidden;
overflow-y: auto;
display: flex;
overflow: hidden;
height: 100%;
padding-inline: 8px;
border-radius: 16px;
}
table {
height: fit-content;
overflow: hidden;
min-width: min(90vw, 16.5cm);
transition: all 1s ease;
}
table abbr {
display: flex;
align-items: center;
justify-content: center;
padding-block: 4px;
padding-inline: 8px;
font-size: 16px;
font-style: normal;
text-decoration: none;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
&.icon {
font-size: 20px;
}
}
th {
text-align: start;
}
td {
display: flex;
gap: 4px;
align-items: stretch;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import type {ChordInfo} from "$lib/undo-redo"
import {changes, ChangeType} from "$lib/undo-redo"
import {createEventDispatcher} from "svelte"
import LL from "../../../i18n/i18n-svelte"
import ActionString from "$lib/components/ActionString.svelte"
import {selectAction} from "./action-selector"
import {serialPort} from "$lib/serial/connection"
import {get} from "svelte/store"
import {inputToAction} from "./input-converter"
export let chord: ChordInfo | undefined = undefined
const dispatch = createEventDispatcher()
let pressedKeys = new Set<number>()
let editing = false
function compare(a: number, b: number) {
return a - b
}
function edit() {
pressedKeys = new Set()
editing = true
}
function keydown(event: KeyboardEvent) {
if (!editing) return
event.preventDefault()
pressedKeys.add(inputToAction(event, get(serialPort)?.device === "X")!)
pressedKeys = pressedKeys
}
function keyup() {
if (!editing) return
editing = false
if (pressedKeys.size < 2) return
if (!chord) return dispatch("submit", [...pressedKeys].sort(compare))
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: [...pressedKeys].sort(compare),
phrase: chord!.phrase,
})
return changes
})
}
function addSpecial(event: MouseEvent) {
selectAction(event, action => {
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: [...chord!.actions, action].sort(compare),
phrase: chord!.phrase,
})
return changes
})
})
}
</script>
<button
class:deleted={chord && chord.deleted}
class:edited={chord && chord.actionsChanged}
class:invalid={chord && chord.actions.toSorted(compare).some((it, i) => chord?.actions[i] !== it)}
class="chord"
on:click={edit}
on:keydown={keydown}
on:keyup={keyup}
>
{#if editing && pressedKeys.size === 0}
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
{:else if !editing && !chord}
<span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if}
<ActionString display="keys" actions={editing ? [...pressedKeys].sort(compare) : chord?.actions ?? []} />
<button class="icon add" on:click|stopPropagation={addSpecial}>add_circle</button>
<sup></sup>
</button>
<style lang="scss">
span {
opacity: 0.5;
}
sup {
translate: 0 -60%;
opacity: 0;
transition: opacity 250ms ease;
}
.add {
font-size: 18px;
margin-inline-start: 4px;
height: 20px;
opacity: 0;
--icon-fill: 1;
}
.chord:hover .add {
opacity: 1;
}
.chord {
position: relative;
display: inline-flex;
gap: 4px;
height: 32px;
margin-inline: 4px;
&:focus-within {
outline: none;
}
}
.chord::after {
content: "";
position: absolute;
top: 50%;
transform-origin: center left;
translate: -6px 0;
scale: 0 1;
width: calc(100% - 32px);
height: 1px;
background: currentcolor;
transition:
scale 250ms ease,
color 250ms ease;
}
.edited {
color: var(--md-sys-color-primary);
& > sup {
opacity: 1;
}
}
.invalid {
color: var(--md-sys-color-error);
}
.deleted {
color: var(--md-sys-color-error);
&::after {
scale: 1;
}
}
</style>

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