196 Commits

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

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

4
.prettierrc Normal file
View File

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

View File

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

View File

@@ -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",
@@ -44,6 +41,7 @@ const config: IconsConfig = {
"save",
"settings_backup_restore",
"sort",
"shopping_bag",
"filter_list",
"settings_power",
"link",
@@ -67,6 +65,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,7 +107,12 @@ 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",
},
}
};
export default config
export default config;

5699
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.6.5"
version = "1.5.0"
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.5.0" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

2
src/app.d.ts vendored
View File

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

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>

24
src/env.d.ts vendored
View File

@@ -1,17 +1,21 @@
/// <references types="vite/client" />
interface ImportMetaEnv {
readonly TAURI_FAMILY?: string
readonly TAURI_PLATFORM_VERSION?: string
readonly TAURI_TARGET_TRIPLE?: string
readonly TAURI_ARCH?: string
readonly TAURI_DEBUG?: boolean
readonly TAURI_PLATFORM_TYPE?: string
readonly TAURI_FAMILY?: string;
readonly TAURI_PLATFORM_VERSION?: string;
readonly TAURI_TARGET_TRIPLE?: string;
readonly TAURI_ARCH?: string;
readonly TAURI_DEBUG?: boolean;
readonly TAURI_PLATFORM_TYPE?: string;
readonly VITE_HOMEPAGE_URL: string;
readonly VITE_BUGS_URL: string;
readonly VITE_DOCS_URL: string;
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
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"
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: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER:
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.",
DOWNLOAD: "Kopie Speichern",
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen",
},
modal: {
@@ -21,12 +30,24 @@ 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 +65,15 @@ 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. Flatpak und Snap Anwendungen benötigen zusätzliche Berechtigungen oder funktionieren möglicherweise gar nicht.",
bootMenu: {
TITLE: "Bootmenü",
REBOOT: "Neustarten",
BOOTLOADER: "Bootloader",
POWER_WARNING:
"Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
},
},
browserWarning: {
@@ -59,15 +85,47 @@ const de = {
INFO_BROWSER_PREFIX:
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP: "Desktop-app herunterladen",
INFO_BROWSER_SUFFIX:
" sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP:
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
},
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 würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
CONFIRM: "Überschreiben",
ABORT: "Überspringen",
},
VOCABULARY: "Vokabelliste",
TRY_TYPING: "Versuche hier zu tippen",
},
layout: {
TITLE: "Layout",
@@ -81,6 +139,6 @@ const de = {
RUN: "Ausführen",
},
},
} satisfies Translation
} satisfies Translation;
export default de
export default de;

View File

@@ -1,31 +1,53 @@
import type {BaseTranslation} from "../i18n-types"
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: "Local backup",
INDIVIDUAL: "Individual backups",
DISCLAIMER:
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore",
},
sync: {
TITLE_READ: "Reading latest changes",
TITLE_WRITE: "Saving changes to device",
RELOAD: "Reload",
},
modal: {
CLOSE: "Close",
},
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,29 +65,66 @@ 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. Flatpak or Snap versions in particular might need additional permissions or may not work at all.",
bootMenu: {
TITLE: "Boot Menu",
REBOOT: "Reboot",
BOOTLOADER: "Bootloader",
POWER_WARNING:
"To reboot from bootloader you need to physically reconnect your device.",
},
},
browserWarning: {
TITLE: "Warning",
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_PREFIX:
"Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_INFIX: "serial connections",
INFO_SERIAL_SUFFIX: ".",
INFO_BROWSER_PREFIX:
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
INFO_BROWSER_SUFFIX: ".",
DOWNLOAD_APP: "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 conflicts with an existing chord. Are you sure you want to overwrite this chord?",
CONFIRM: "Overwrite",
ABORT: "Skip",
},
VOCABULARY: "Vocabulary",
TRY_TYPING: "Try typing here",
},
layout: {
TITLE: "Layout",
@@ -79,6 +138,6 @@ const en = {
RUN: "Run",
},
},
} satisfies BaseTranslation
} satisfies BaseTranslation;
export default en
export default en;

View File

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

View File

@@ -0,0 +1,144 @@
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
43:
id: "+"
title: Plus
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.
@@ -91,3 +91,19 @@ actions:
<<: *tertiary_keymap
id: "KM_3_R"
variant: right
576:
id: ACTION_DELAY_1000
icon: clock_loader_90
description: Wait for one second
577:
id: ACTION_DELAY_100
icon: clock_loader_60
description: Wait for 100 milliseconds
578:
id: ACTION_DELAY_10
icon: clock_loader_40
description: Wait for 10 milliseconds
579:
id: ACTION_DELAY_1
icon: clock_loader_10
description: Wait for one millisecond

View File

@@ -4,41 +4,52 @@ icon: keyboard
actions:
512: &left_ctrl
id: "LEFT_CTRL"
display: CTRL
title: Control Keyboard Modifier
keyCode: ControlLeft
variant: left
icon: keyboard_control_key
513: &left_shift
id: "LEFT_SHIFT"
title: Shift Keyboard Modifier
keyCode: ShiftLeft
variant: left
icon: shift
514: &left_alt
id: "LEFT_ALT"
display: ALT
title: Alt Keyboard Modifier
keyCode: AltLeft
variant: left
icon: keyboard_option_key
515: &left_gui
id: "LEFT_GUI"
title: GUI Keyboard Modifier
keyCode: MetaLeft
icon: apps
variant: left
icon: keyboard_command_key
516:
variationOf: 512
<<: *left_ctrl
id: "RIGHT_CTRL"
keyCode: ControlRight
variant: right
517:
variationOf: 513
<<: *left_shift
id: "RIGHT_SHIFT"
keyCode: ShiftRight
variant: right
518:
variationOf: 514
<<: *left_alt
id: "RIGHT_ALT"
keyCode: AltRight
variant: right
519:
variationOf: 515
<<: *left_gui
id: "RIGHT_GUI"
keyCode: MetaRight
variant: right
520:
id: "RELEASE_MOD"
title: Release all keyboard modifiers
@@ -51,3 +62,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

@@ -1,16 +1,19 @@
export interface KeymapCategory {
name: string
description: string
icon?: string
type?: "unassigned"
actions: Record<number, Partial<ActionInfo>>
name: string;
description: string;
icon?: string;
display?: string;
type?: "unassigned";
actions: Record<number, Partial<ActionInfo>>;
}
export interface ActionInfo {
id: string
title: string
icon: string
description: string
variant: "left" | "right"
variantOf: number
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:
@@ -841,34 +944,42 @@ actions:
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 +988,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 +1020,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,86 @@
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

View File

@@ -0,0 +1,36 @@
[
"You can use DUP+i to create chords on the fly in any text box",
"This site is open source! Check out the full source code on GitHub in the bottom left",
"Two letter chords can be activated accidentally in chentry. Be cautious around them",
"More inputs in a chord increase the tolerance, making them easier to activate",
"The maximum number of outputs in a chord is 256",
"You can create backups of your device on the top right",
"For programming you should set your auto-delete timeout to about 200ms",
"Large parts of this site were written on a CC1",
"I use VIM btw...",
"I use NixOS btw...",
"You can hold shift on the undo button to undo all changes",
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
"Spurring is a chording only mode which is more advanced, but can greatly imporve typing speed when mastered",
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
"Don't be afraid to delete chords you keep getting wrong",
"Most people find it easier to start their chord library from scratch rather than learning someone else's",
"A common techinque to deal with conflicts is to add DUP or the same key mirrored on the other hand",
"A longer chord is not always more difficult",
"Riley Keen made headlines when his Monkeytype score of 500WPM using a CC1 got him banned off the site",
"A 3d press refers to pressing down into a 5-way switch",
"The serial communication protocol used by CCOS is documented on docs.charachorder.com",
"The 'CCOS is ready' message can be turned off in the settings",
"Most people using the CC1 don't change the a-z key layout, as further modification provides very little benefit",
"Using VIM on the default CC1 a-z layout is perfectly doable, it's just a matter of getting used to it",
"You can use Nexus to track words you might want to add to your chord library",
"The CC1 default layout was 80% science, 20% art",
"There is little to no reason to use hjkl in VIM on a CC1 since the arrows keys are so close already",
"The device manager automatically creates a backup for you when you reboot your device into the bootloader"
]

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

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

@@ -0,0 +1,192 @@
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 {
}
}
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) return;
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
<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.get(action) ?? { code: action }
: action;
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
$: tooltip =
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: "");
</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,10 +1,14 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import LL from "../../i18n/i18n-svelte";
import Action from "$lib/components/Action.svelte";
export let id: number | KeyInfo
export let id: number | KeyInfo;
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
$: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
| number
| KeyInfo;
</script>
<button on:click>
@@ -21,8 +25,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,20 +45,33 @@
align-items: center;
width: 100%;
height: auto;
margin: 0;
padding: 8px;
font-family: "Noto Sans Mono", monospace;
color: inherit;
background: transparent;
border: none;
border-radius: 8px;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
@media not (forced-colors: active) {
color: inherit;
background: transparent;
border: none;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px;
&:hover {
color: ActiveText;
}
}
}
@@ -62,17 +85,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

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

@@ -1,22 +1,28 @@
<script lang="ts">
import {serialLog, serialPort} from "$lib/serial/connection"
import {slide} from "svelte/transition"
import { serialLog, serialPort } from "$lib/serial/connection";
import { slide } from "svelte/transition";
function submit(event: Event) {
event.preventDefault()
$serialPort.send(value.trim())
value = ""
io.scrollTo({top: io.scrollHeight})
event.preventDefault();
$serialPort?.send(0, value.trim());
value = "";
io.scrollTo({ top: io.scrollHeight });
}
let value: string
let io: HTMLDivElement
let value: string;
let io: HTMLDivElement;
</script>
<form on:submit={submit}>
<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,111 +1,145 @@
<script lang="ts">
import {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 {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { createEventDispatcher, onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title";
export let currentAction: number
export let currentAction: number | undefined = undefined;
export let nextAction: number | undefined = undefined;
const index = new Index({tokenize: "full"})
for (const action of Object.values(KEYMAP_CODES)) {
index?.add(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
)
onMount(() => {
searchBox.focus();
});
const index = new FlexSearch.Index({ tokenize: "full" });
createIndex();
async function createIndex() {
for (const [, action] of KEYMAP_CODES) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
Object.values(KEYMAP_CODES)
.filter(it => !!it.id)
.map(it => [it.id, it] as const),
)
function search() {
results = index!.search(searchBox.value)
exact = exactIndex[searchBox.value]?.code
code = Number(searchBox.value)
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
exact = KEYMAP_IDS.get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
dispatch("select", id)
dispatch("select", id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact)
} else if (event.shiftKey && event.key === "Escape") {
dispatch("select", 0)
} else if (event.key === "Escape") {
dispatch("close")
dispatch("select", exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)")
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
element.querySelector("button")?.focus();
}
} else {
searchBox.focus()
return
searchBox.focus();
return;
}
event.preventDefault()
event.preventDefault();
}
let results: number[] = []
let exact: number | undefined = undefined
let code: number = Number.NaN
let results: number[] = [];
let exact: number | undefined = undefined;
let code: number = Number.NaN;
const dispatch = createEventDispatcher()
let searchBox: HTMLInputElement
let resultList: HTMLUListElement
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 => {
on:keypress={(event) => {
if (event.key === "Enter") {
select(exact)
select(exact);
}
}}
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,14 +150,46 @@
<li>Action code is out of range</li>
{/if}
{/if}
{#each results as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from(KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
{/if}
</ul>
</div>
</dialog>
<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;
@@ -133,6 +199,7 @@
height: 100%;
background: rgba(0 0 0 / 60%);
border: none;
}
@@ -154,10 +221,15 @@
background: var(--md-sys-color-background);
}
}
h2 {
margin-inline: 16px;
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
@@ -165,42 +237,11 @@
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;
@@ -213,6 +254,10 @@
background: var(--md-sys-color-background);
border-radius: 16px;
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
@@ -227,7 +272,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;
@@ -280,27 +325,9 @@
background: var(--md-sys-color-primary);
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;
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { compileLayout } from "$lib/serialization/visual-layout";
import type {
VisualLayout,
CompiledLayoutKey,
} from "$lib/serialization/visual-layout";
import { deviceLayout } from "$lib/serial/connection";
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
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];
if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
if (!nextAction || !currentAction) return;
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}
transition:fly={{ y: 48, easing: expoOut }}
>
{#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,92 @@
<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.get(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 : `var(--inactive-opacity, ${inactiveOpacity})`}
style:scale={isActive ? 1 : `var(--inactive-scale, ${inactiveScale})`}
style:translate={isActive
? "0 0 0"
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
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;
@media (prefers-contrast: more) {
--inactive-opacity: 0.8;
--inactive-scale: 0.7;
}
}
text:focus-within {
outline: none;
}
</style>

View File

@@ -0,0 +1,133 @@
<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,70 @@
<script lang="ts">
import {serialPort} from "$lib/serial/connection"
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
import { serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
$: device = $serialPort?.device ?? "ONE"
let activeLayer = 0
$: device = $serialPort?.device;
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const
] as const;
const layouts = {
ONE: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
),
LITE: () =>
import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as VisualLayout,
),
X: () =>
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
<div>
<fieldset>
{#each layers as [title, icon, value]}
<button
{title}
class="icon"
on:click={() => (activeLayer = value)}
class:active={activeLayer === value}
>
{icon}
</button>
{/each}
</fieldset>
<div class="container">
{#if device}
{#await layouts[device]() then visualLayout}
<fieldset transition:fade>
{#each layers as [title, icon, value]}
<button
class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }}
on:click={() => ($activeLayer = value)}
class:active={$activeLayer === value}
>
{icon}
</button>
{/each}
</fieldset>
{#if device === "ONE"}
<LayoutCC1 bind:activeLayer />
{:else}
<p>Unsupported device ({$serialPort?.device})</p>
<GenericLayout {visualLayout} />
{/await}
{/if}
</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,8 +72,7 @@
align-items: center;
justify-content: center;
margin-block-end: -36px;
padding: 0;
padding: 8px;
border: none;
}
@@ -68,16 +98,23 @@
font-size: 32px;
border-radius: 50%;
outline: 8px solid var(--md-sys-color-background);
}
&:first-child,
&:last-child {
aspect-ratio: unset;
height: unset;
}
&:first-child {
padding-inline-end: 16px;
margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px;
}
&:last-child {
padding-inline-start: 16px;
margin-inline-start: -8px;
padding-inline: 24px 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,41 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte";
export let title: string;
export let message: string | undefined;
export let abortTitle: string;
export let confirmTitle: string;
export let actions: number[] = [];
const dispatch = createEventDispatcher();
</script>
<Dialog>
<h1>{@html title}</h1>
{#if message}
<p>{@html message}</p>
{/if}
<p><ActionString {actions} /></p>
<div class="buttons">
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}
>{confirmTitle}</button
>
</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,217 @@
<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 as changes, i}
{@const layer = i + 1}
{#if changes.length > 0}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({
changes: changes.length,
layer,
})}
</label>
</h4>
</li>
{/if}
{/each}
</ul>
</li>
{/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,33 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
export async function askForConfirmation(
title: string,
message: string,
confirmTitle: string,
abortTitle: string,
actions: number[],
): Promise<boolean> {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title,
message,
confirmTitle,
abortTitle,
actions,
},
});
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)
},
}
}

View File

@@ -73,8 +73,9 @@
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020,
U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
/* noto-sans-mono-latin-wght-normal */
@@ -86,7 +87,7 @@
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301,
U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,63 +1,108 @@
import {get, writable} from "svelte/store"
import {CharaDevice} from "$lib/serial/device"
import type {Chord} from "$lib/serial/chord"
import type {Writable} from "svelte/store"
import type {CharaLayout} from "$lib/serialization/layout"
import {persistentWritable} from "$lib/storage"
import {userPreferences} from "$lib/preferences"
import { get, writable } from "svelte/store";
import { CharaDevice } from "$lib/serial/device";
import type { Chord } from "$lib/serial/chord";
import type { Writable } from "svelte/store";
import type { CharaLayout } from "$lib/serialization/layout";
import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences";
import settingInfo from "$lib/assets/settings.yml";
export const serialPort = writable<CharaDevice | undefined>()
export const serialPort = writable<CharaDevice | undefined>();
export interface SerialLogEntry {
type: "input" | "output" | "system"
value: string
type: "input" | "output" | "system";
value: string;
}
export const serialLog = writable<SerialLogEntry[]>([])
export const serialLog = writable<SerialLogEntry[]>([]);
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>
/**
* 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 changes = persistentWritable<Change[]>("changes", [])
export const settings = writable({})
export const unsavedChanges = writable(new Map<number, number>())
export const highlightActions: Writable<number[]> = writable([])
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice()
await device.init(manual)
serialPort.set(device)
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
serialPort.set(device);
await sync();
}
syncStatus.set("downloading")
const parsedLayout: CharaLayout = [[], [], []]
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++) {
parsedLayout[layer - 1][i] = await device.getLayoutKey(layer, 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 = []
const chordInfo = [];
for (let i = 0; i < chordCount; i++) {
chordInfo.push(await device.getChord(i))
chordInfo.push(await device.getChord(i));
progressTick();
}
chords.set(chordInfo)
syncStatus.set("done")
deviceChords.set(chordInfo);
syncStatus.set("done");
syncProgress.set(undefined);
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
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,58 +1,83 @@
import type {Action} from "svelte/action"
import {serialPort, unsavedChanges} from "$lib/serial/connection"
import {get} from "svelte/store"
import type { Action } from "svelte/action";
import { changes, ChangeType, settings } from "$lib/undo-redo";
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
node: HTMLInputElement,
{id, inverse, scale},
export const setting: Action<
HTMLInputElement | HTMLSelectElement,
{ id: number; inverse?: number; scale?: number }
> = function (
node: HTMLInputElement | HTMLSelectElement,
{ id, inverse, scale },
) {
node.setAttribute("disabled", "")
const type = node.getAttribute("type") as "number" | "checkbox"
node.setAttribute("disabled", "");
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
const isNumeric =
type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min")
? Number(node.getAttribute("min"))
: undefined;
const max = node.hasAttribute("max")
? Number(node.getAttribute("max"))
: undefined;
const unsubscribe = serialPort.subscribe(async port => {
if (port) {
if (type === "number") {
const value = Number(await port.getSetting(id).then(it => it.toString()))
const unsubscribe = settings.subscribe(async (settings) => {
if (id in settings) {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
inverse !== undefined ? inverse / value : scale !== undefined ? scale * value : value
).toString()
inverse !== undefined
? inverse / value
: scale !== undefined
? scale * value
: value
).toString();
} else {
node.checked = await port.getSetting(id).then(it => it !== 0)
node.checked = value !== 0;
}
node.removeAttribute("disabled")
} else {
node.setAttribute("disabled", "")
}
})
async function listener(event: Event) {
const currentValue = await get(serialPort)!.getSetting(id)
let value = 0
if (type === "number") {
value = Number((event as InputEvent).data)
if (Number.isNaN(value)) return
value = inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : 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)
if (isApplied) {
node.classList.remove("pending-changes");
} else {
node.classList.add("pending-changes");
}
return it
})
node.removeAttribute("disabled");
} else {
node.setAttribute("disabled", "");
}
});
async function listener() {
let value: number;
if (isNumeric) {
value = Number(node.value);
if (Number.isNaN(value)) return;
value = Math.floor(
inverse !== undefined
? inverse / value
: scale !== undefined
? value / scale
: value,
);
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
} else {
value = node.checked ? 1 : 0;
}
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)
unsubscribe()
node.removeEventListener("change", listener);
unsubscribe();
},
}
}
};
};

View File

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

View File

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

View File

@@ -1,15 +1,23 @@
export interface CharaFile<T extends string> {
charaVersion: 1
type: T
charaVersion: 1;
type: T;
}
export interface CharaLayoutFile extends CharaFile<"layout"> {
device: "one" | "lite" | string
layout: [number[], number[], number[]]
device?: "ONE" | "LITE" | string;
layout: [number[], number[], number[]];
}
export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]]
chords: [number[], number[]][];
}
export 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,13 +1,19 @@
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<
[Exclude<keyof Extract<CharaFiles, {type: K}>, keyof CharaFile<any>>, (typeof CHARA_FILE_TYPES)[number]]
>
}
[
Exclude<keyof Extract<CharaFiles, { type: K }>, keyof CharaFile<any>>,
(typeof CHARA_FILE_TYPES)[number],
]
>;
};
const keys: CharaLayoutOrder = {
layout: [
@@ -15,47 +21,61 @@ const keys: CharaLayoutOrder = {
["device", "string"],
],
chords: [["chords", "array"]],
}
settings: [["settings", "array"]],
};
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const
export const CHARA_FILE_TYPES = [
"unknown",
"number",
"string",
"array",
] as const;
const sep = "\n"
const sep = "\n";
export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`
export async function charaFileToUriComponent<T extends CharaFiles>(
file: T,
): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`;
for (const [key, type] of keys[file.type]) {
const value = file[key as keyof T]
url += sep
const value = file[key as keyof T];
url += sep;
if (type === "string") {
url += value as string
url += value as string;
} else if (type === "array") {
const stream = new Blob([serializeActionArray(value as ActionArray)])
.stream()
.pipeThrough(new CompressionStream("deflate"))
url += await toBase64(await new Response(stream).blob())
.pipeThrough(new CompressionStream("deflate"));
url += await toBase64(await new Response(stream).blob());
} else {
throw new Error("Not implemented")
throw new Error("Not implemented");
}
}
return url
return url;
}
export async function charaFileFromUriComponent<T extends CharaFiles>(uriComponent: string): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep)
const file: any = {type: fileType, version: Number(version)}
export async function charaFileFromUriComponent<T extends CharaFiles>(
uriComponent: string,
fetch = window.fetch,
): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep);
const file: any = { type: fileType, charaVersion: Number(version) };
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
file[key] = value;
} else if (type === "array") {
const stream = (await fromBase64(value)).stream().pipeThrough(new DecompressionStream("deflate"))
const actions = new Uint8Array(await new Response(stream).arrayBuffer())
file[key] = deserializeActionArray(actions)
const stream = (await fromBase64(value, fetch))
.stream()
.pipeThrough(new DecompressionStream("deflate"));
const actions = new Uint8Array(await new Response(stream).arrayBuffer());
console.log(actions);
file[key] = deserializeActionArray(actions);
}
}
return file
return file;
}

View File

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

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,109 @@
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;
@media not (forced-colors: active) {
color: currentcolor;
background: transparent;
border: none;
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
color: ButtonText;
}
border-radius: 32px;
transition: all 250ms ease;
&.icon {
display: inline-flex;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
font-size: 24px;
border-radius: 50%;
@media (forced-colors: active) {
padding: 2px;
margin: 2px;
}
}
&.compact {
height: 32px;
}
}
@media not (forced-colors: active) {
label:has(input):hover,
.button:hover:not(:active),
a:hover:not(:active),
button:hover:not(:active) {
filter: brightness(70%);
transition: filter 250ms ease;
&:has(:checked),
&.active {
filter: brightness(120%);
}
&:disabled,
&.disabled {
opacity: 0.5;
filter: none;
}
}
}
@media (forced-colors: active) {
label:has(input) .button,
a button {
&:hover {
color: ActiveText;
}
&.active,
&:active {
color: SelectedItemText;
background: SelectedItem;
}
}
}
.disabled,
:disabled {
pointer-events: none;
@media not (forced-colors: active) {
opacity: 0.5;
}
@media (forced-colors: active) {
color: GrayText;
}
}

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