├── .env.template ├── .eslintrc ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── api ├── config.js ├── index.js ├── routes │ ├── application.js │ ├── github.js │ └── keyboards.js └── services │ ├── github │ ├── README.md │ ├── api.js │ ├── auth.js │ ├── files.js │ ├── index.js │ └── installations.js │ ├── index.js │ └── zmk │ ├── data │ ├── LICENSE │ ├── zmk-behaviors.json │ └── zmk-keycodes.json │ ├── defaults.js │ ├── index.js │ ├── keymap.js │ ├── layout.js │ └── local-source.js ├── app ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── editor-icon.png │ └── index.html └── src │ ├── App.css │ ├── App.js │ ├── Common │ ├── DialogBox.js │ ├── Icon.js │ ├── IconButton.js │ ├── Loader.js │ ├── Modal.js │ ├── Selector.js │ ├── Spinner.js │ └── spinner.module.css │ ├── GitHubLink.js │ ├── Keyboard │ ├── Keyboard.js │ ├── KeyboardLayout.js │ ├── Keys │ │ ├── Key.js │ │ ├── KeyParamlist.js │ │ ├── KeyValue.js │ │ ├── keyPropTypes.js │ │ ├── styles.module.css │ │ └── util.js │ ├── LayerSelector.js │ └── styles.module.css │ ├── Pickers │ ├── Github │ │ ├── InvalidRepo.js │ │ ├── Picker.js │ │ ├── ValidationErrors.js │ │ ├── api.js │ │ └── storage.js │ └── KeyboardPicker.js │ ├── ValuePicker │ ├── index.js │ └── style.module.css │ ├── api.js │ ├── config.js │ ├── index.css │ ├── index.js │ ├── key-units.js │ ├── keycodes.js │ ├── keymap.js │ ├── layout.js │ ├── providers.js │ └── styles.module.css ├── index.js ├── keymap-editor-demo.mov ├── old-readme.md ├── package-lock.json ├── package.json ├── running-locally.md └── screenshots ├── editor-screenshot-combos.png ├── editor-screenshot-dark.png ├── editor-screenshot-darkmode.png ├── editor-screenshot-light.png ├── editor-screenshot-macros.png ├── editor-screenshot.png └── layout-example.png /.env.template: -------------------------------------------------------------------------------- 1 | GITHUB_APP_ID= 2 | GITHUB_APP_NAME= 3 | GITHUB_CLIENT_ID= 4 | GITHUB_CLIENT_SECRET= 5 | GITHUB_OAUTH_CALLBACK_URL= 6 | APP_BASE_URL=http://localhost:3000 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | .env.test 60 | 61 | # parcel-bundler cache (https://parceljs.org/) 62 | .cache 63 | 64 | # next.js build output 65 | .next 66 | 67 | # nuxt.js build output 68 | .nuxt 69 | 70 | # vuepress build output 71 | .vuepress/dist 72 | 73 | # Serverless directories 74 | .serverless/ 75 | 76 | # FuseBox cache 77 | .fusebox/ 78 | 79 | # DynamoDB Local files 80 | .dynamodb/ 81 | 82 | dist 83 | qmk_firmware 84 | zmk-config 85 | 86 | private-key.pem 87 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nick Coutsos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keymap Editor Icon Keymap Editor 2 | 3 | A browser app to edit ZMK keymaps. Although one of the goals for this project is 4 | to simplify the manual effort of keymap editing for the end user, is isn't a 5 | substitute for understanding ZMK. Be sure to read ZMK's documentation in order 6 | to fully leverage this app's functionality. 7 | 8 | **Try it now!** Go to the [Keymap Editor] and try it out with the built-in 9 | [keymap-editor-demo-crkbd] before setting up your own repo. 10 | 11 | **[Talk to me! 🗣](https://github.com/nickcoutsos/keymap-editor/discussions)** 12 | 13 | I'd love to know how the Keymap Editor is working out for you! Has it helped you 14 | with managing your own keymaps, are you struggling with functionality, have you 15 | created your own keyboard and directed users here? 16 | 17 | I want to know about all of that. I'm not taking any donations, the only thing 18 | driving this work forward is knowing what is or isn't helping people. 19 | 20 | 21 | 22 | 23 | Shows a screenshot of the Keymap Editor application featuring a graphical layout of the Corne Keyboard with a keymap loaded from the nickcoutsos/keymap-editor-demo-crkbd GitHub repository. 24 | 25 | 26 | > **Note** 27 | > 28 | > **Source code updates are no longer shared here** 29 | > 30 | > I have been developing this application on and off since August 2020, but more 31 | > recent source changes have not been published and this isn't likely to change 32 | > any time soon. For more information see [Wiki: Source Code Updates] 33 | > 34 | > If you do want to use the available source code as-is, you may wish to review 35 | > the [original README](old-readme.md). 36 | 37 | ## Features 38 | 39 | * WYSIWYG keymap editing 40 | * Multiple keymap sources: 41 | * GitHub repositories 42 | * Clipboard 43 | * File system\* 44 | * [Dark mode!](./screenshots/editor-screenshot-darkmode.png) 45 | * Conditional Layers 46 | * [Combo editing](./screenshots/editor-screenshot-combos.png) 47 | * [Macro editing](./screenshots/editor-screenshot-macros.png) (including support for creating/using parameterized macros) 48 | * Behavior editing (creation and re-configuration) 49 | * Auto-generated layouts for ZMK's supported keyboards\*\* 50 | * Rotary encoders 51 | * Multiple keymaps 52 | 53 | \*_File system web APIs are currently only supported in Chromium-based browsers_ 54 | 55 | \*\*_Auto-generated layouts are meant as a starting-off point and are provided for most keyboards available in the ZMK repo and may need customization -- I own exactly one keyboard, I don't know all the layouts._ 56 | 57 | 58 | _Read more: [Wiki:Features]_ 59 | 60 | 61 | ## Usage 62 | 63 | ### Local 64 | 65 | This project runs as a web application, but there are still options for working 66 | with offline ZMK keymaps: 67 | 68 | In the editor you can choose the _Clipboard_ keymap source and paste in the 69 | contents of your ZMK `.keymap` file, and if you're using a Chromium-based web 70 | browser you can alternatively use the _FileSystem_ source to read and make 71 | changes to select `.keymap` files directly. 72 | 73 | Actual firmware builds are outside of the scope of this project, so if you're 74 | working on local keymap data it is assumed that you have a local ZMK development 75 | environment or some other means of running builds. 76 | 77 | ### Web Integrations 78 | 79 | This editor includes a GitHub integration. You can load the web app and grant it 80 | access to your public or private zmk-config repos. Changes to your keymap are 81 | committed right back to the repository so you only ever need to leave the app to 82 | download and flash firmware. 83 | 84 | ## License 85 | 86 | The code in this repo is available under the MIT license. 87 | 88 | The collection of ZMK keycodes is taken from the ZMK documentation under the MIT 89 | license as well. 90 | 91 | [Keymap Editor]: https://nickcoutsos.github.io/keymap-editor/ 92 | [keymap-editor-demo-crkbd]: https://github.com/nickcoutsos/keymap-editor-demo-crkbd/ 93 | [keymap-editor-demo-crkbd template]: https://github.com/nickcoutsos/keymap-editor-demo-crkbd/generate 94 | [Wiki:Automatic Layout Generation]: https://github.com/nickcoutsos/keymap-editor/wiki/Defining-keyboard-layouts#automatic-layout-generation 95 | [Wiki:Features]: https://github.com/nickcoutsos/keymap-editor/wiki/Features 96 | [Wiki: Source Code Updates]: https://github.com/nickcoutsos/keymap-editor/wiki/Source-Code-Updates 97 | -------------------------------------------------------------------------------- /api/config.js: -------------------------------------------------------------------------------- 1 | const process = require('process') 2 | require('dotenv/config') 3 | 4 | const PORT = process.env.PORT || 8080 5 | const ENABLE_DEV_SERVER = process.env.ENABLE_DEV_SERVER 6 | const ENABLE_GITHUB = process.env.ENABLE_GITHUB 7 | const GITHUB_APP_NAME = process.env.GITHUB_APP_NAME 8 | const GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY 9 | const GITHUB_APP_ID = process.env.GITHUB_APP_ID 10 | const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID 11 | const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET 12 | const GITHUB_OAUTH_CALLBACK_URL = process.env.GITHUB_OAUTH_CALLBACK_URL 13 | const APP_BASE_URL = process.env.APP_BASE_URL 14 | 15 | module.exports = { 16 | PORT, 17 | ENABLE_DEV_SERVER, 18 | ENABLE_GITHUB, 19 | GITHUB_APP_NAME, 20 | GITHUB_APP_PRIVATE_KEY, 21 | GITHUB_APP_ID, 22 | GITHUB_CLIENT_ID, 23 | GITHUB_CLIENT_SECRET, 24 | GITHUB_OAUTH_CALLBACK_URL, 25 | APP_BASE_URL 26 | } 27 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const bodyParser = require('body-parser') 3 | const cors = require('cors') 4 | const morgan = require('morgan') 5 | 6 | const config = require('./config') 7 | const applicationInit = require('./routes/application') 8 | const keyboards = require('./routes/keyboards') 9 | 10 | const app = express() 11 | 12 | const { origin } = new URL(config.APP_BASE_URL) 13 | 14 | app.use(bodyParser.json()) 15 | app.use(cors({ origin })) 16 | 17 | if (config.ENABLE_DEV_SERVER) { 18 | applicationInit(app) 19 | } 20 | 21 | app.use(morgan('dev')) 22 | app.get('/health', (req, res) => res.sendStatus(200)) 23 | 24 | app.use(keyboards) 25 | config.ENABLE_GITHUB && app.use('/github', require('./routes/github')) 26 | 27 | module.exports = app 28 | -------------------------------------------------------------------------------- /api/routes/application.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const path = require('path') 3 | 4 | const config = require('../config') 5 | 6 | const appDir = path.join(__dirname, '..', '..', 'app') 7 | const API_BASE_URL = 'http://localhost:8080' 8 | const APP_BASE_URL = 'http://localhost:3000' 9 | 10 | function init (app) { 11 | const opts = { 12 | cwd: appDir, 13 | env: Object.assign({}, process.env, { 14 | REACT_APP_ENABLE_LOCAL: true, 15 | REACT_APP_ENABLE_GITHUB: config.ENABLE_GITHUB, 16 | REACT_APP_GITHUB_APP_NAME: config.GITHUB_APP_NAME, 17 | REACT_APP_API_BASE_URL: API_BASE_URL, 18 | REACT_APP_APP_BASE_URL: APP_BASE_URL 19 | }) 20 | } 21 | 22 | childProcess.execFile('npm', ['start'], opts, err => { 23 | console.error(err) 24 | console.error('Application serving failed') 25 | process.exit(1) 26 | }) 27 | 28 | app.get('/', (req, res) => res.redirect(APP_BASE_URL)) 29 | } 30 | 31 | module.exports = init 32 | -------------------------------------------------------------------------------- /api/routes/github.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | 3 | const { 4 | getOauthToken, 5 | getOauthUser, 6 | getUserToken, 7 | verifyUserToken, 8 | fetchInstallationRepos, 9 | fetchRepoBranches, 10 | fetchKeyboardFiles, 11 | createOauthFlowUrl, 12 | createOauthReturnUrl, 13 | commitChanges 14 | } = require('../services/github') 15 | const { createInstallationToken } = require('../services/github/auth') 16 | const { MissingRepoFile, findCodeKeymap } = require('../services/github/files') 17 | const { parseKeymap, validateKeymapJson, KeymapValidationError } = require('../services/zmk/keymap') 18 | const { validateInfoJson, InfoValidationError } = require('../services/zmk/layout') 19 | 20 | const router = Router() 21 | 22 | const authorize = async (req, res) => { 23 | if (req.query.code) { 24 | try { 25 | const { data: oauth } = await getOauthToken(req.query.code) 26 | const { data: user } = await getOauthUser(oauth.access_token) 27 | const token = getUserToken(oauth, user) 28 | res.redirect(createOauthReturnUrl(token)) 29 | } catch (err) { 30 | const message = err.response ? err.response.data : err 31 | console.error(message) 32 | res.sendStatus(500) 33 | } 34 | } else { 35 | res.redirect(createOauthFlowUrl()) 36 | } 37 | } 38 | 39 | const handleError = (err, req, res, next) => { 40 | if (err.response && err.response.status === 401) { 41 | console.error('Received upstream authentication error', err.response.data) 42 | return res.sendStatus(401) 43 | } else { 44 | const message = err.response ? `[${err.response.status}] ${err.response.data}` : err 45 | console.error(message, err) 46 | } 47 | 48 | res.sendStatus(500) 49 | } 50 | 51 | const authenticate = (req, res, next) => { 52 | const header = req.headers.authorization 53 | const token = (header || '').split(' ')[1] 54 | 55 | if (!token) { 56 | return res.sendStatus(401) 57 | } 58 | 59 | try { 60 | req.user = verifyUserToken(token) 61 | } catch (err) { 62 | return res.sendStatus(401) 63 | } 64 | 65 | next() 66 | } 67 | 68 | const getInstallation = async (req, res, next) => { 69 | const { user } = req 70 | const { sub: username, oauth_access_token: userToken } = user 71 | 72 | try { 73 | const installationRepos = await fetchInstallationRepos(userToken) 74 | if (installationRepos.installations.length === 0) { 75 | console.log(`User ${username} does not have an active app installation.`) 76 | } 77 | 78 | res.json(installationRepos) 79 | } catch (err) { 80 | next(err) 81 | } 82 | } 83 | 84 | const getBranches = async (req, res, next) => { 85 | const { installationId, repository } = req.params 86 | 87 | try { 88 | const { data: { token: installationToken } } = await createInstallationToken(installationId) 89 | const branches = await fetchRepoBranches(installationToken, repository) 90 | 91 | res.json(branches) 92 | } catch (err) { 93 | next(err) 94 | } 95 | } 96 | 97 | const getKeyboardFiles = async (req, res, next) => { 98 | const { installationId, repository } = req.params 99 | const { branch } = req.query 100 | 101 | try { 102 | const { info, keymap } = await fetchKeyboardFiles(installationId, repository, branch) 103 | validateInfoJson(info) 104 | validateKeymapJson(keymap) 105 | 106 | res.json({ 107 | info, 108 | keymap: parseKeymap(keymap) 109 | }) 110 | } catch (err) { 111 | if (err instanceof MissingRepoFile) { 112 | console.error(`Validation error in ${repository} (${branch}):`, err.constructor.name, err.errors) 113 | return res.status(400).json({ 114 | name: err.constructor.name, 115 | path: err.path, 116 | errors: err.errors 117 | }) 118 | } else if (err instanceof InfoValidationError || err instanceof KeymapValidationError) { 119 | console.error(`Validation error in ${repository} (${branch}):`, err.constructor.name, err.errors) 120 | return res.status(400).json({ 121 | name: err.name, 122 | errors: err.errors 123 | }) 124 | } 125 | 126 | next(err) 127 | } 128 | } 129 | 130 | const updateKeyboardFiles = async (req, res, next) => { 131 | const { installationId, repository, branch } = req.params 132 | const { keymap, layout } = req.body 133 | 134 | try { 135 | await commitChanges(installationId, repository, branch, layout, keymap) 136 | } catch (err) { 137 | return next(err) 138 | } 139 | 140 | res.sendStatus(200) 141 | } 142 | 143 | const receiveWebhook = (req, res) => { 144 | res.sendStatus(200) 145 | } 146 | 147 | router.get('/authorize', authorize) 148 | router.get('/installation/:installationId/:repository/branches', authenticate, getBranches) 149 | router.get('/installation', authenticate, getInstallation) 150 | router.get('/keyboard-files/:installationId/:repository', authenticate, getKeyboardFiles) 151 | router.post('/keyboard-files/:installationId/:repository/:branch', authenticate, updateKeyboardFiles) 152 | router.post('/webhook', receiveWebhook) 153 | router.use(handleError) 154 | 155 | module.exports = router 156 | -------------------------------------------------------------------------------- /api/routes/keyboards.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | const zmk = require('../services/zmk') 3 | 4 | const router = Router() 5 | 6 | router.get('/behaviors', (req, res) => res.json(zmk.loadBehaviors())) 7 | router.get('/keycodes', (req, res) => res.json(zmk.loadKeycodes())) 8 | router.get('/layout', (req, res) => res.json(zmk.loadLayout())) 9 | router.get('/keymap', (req, res) => res.json(zmk.loadKeymap())) 10 | router.post('/keymap', (req, res) => { 11 | const keymap = req.body 12 | const layout = zmk.loadLayout() 13 | const generatedKeymap = zmk.generateKeymap(layout, keymap) 14 | const exportStdout = zmk.exportKeymap(generatedKeymap, 'flash' in req.query, err => { 15 | if (err) { 16 | res.status(500).send(err) 17 | return 18 | } 19 | 20 | res.send() 21 | }) 22 | 23 | // exportStdout.stdout.on('data', data => { 24 | // for (let sub of subscribers) { 25 | // sub.send(data) 26 | // } 27 | // }) 28 | }) 29 | 30 | module.exports = router 31 | -------------------------------------------------------------------------------- /api/services/github/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Integration 2 | 3 | The primary use case for the keymap editor supports an integration with GitHub 4 | to fetch, modify, and commit keymaps to any enabled repositories. 5 | 6 | ## Terminology 7 | 8 | * **App** 9 | * A GitHub App is an integration that allows a third party to make requests 10 | to GitHub's API as another user. 11 | * As far as GitHub is concerned this would refer to the combination of the 12 | backend and frontend software in this repo. To avoid confusion I'll try to 13 | distinguish between the _GitHub App_ and the _Web App_. 14 | * **Installation** 15 | * This is the user's agreement to let the _GitHub App_ perform activies on 16 | their behalf. 17 | * It specifies a selection or repositories (or all owned repos) that the 18 | GitHub App may access. 19 | 20 | ## Configuration 21 | 22 | All configuration is provided to the app via environment variables. 23 | _(See [The Twelve-Factor App](https://12factor.net/config))_ 24 | 25 | If you wish to create a local dev environment or your own hosted instance of the 26 | editor, create a GitHub app, copy `.env.template` to `.env`, and fill in the 27 | following config values: 28 | 29 | * `GITHUB_APP_ID`: the app id generated by GitHub 30 | * `GITHUB_APP_NAME`: the name you chose for the app 31 | * `GITHUB_CLIENT_ID`: client id generated by GitHub 32 | * `GITHUB_CLIENT_SECRET`: client secret generated by GitHub 33 | * `GITHUB_OAUTH_CALLBACK_URL`: the public URL that GitHub will use to complete 34 | the OAuth flow. This will be somthing like 35 | `https:///github/authorize>` 36 | * `GITHUB_APP_PRIVATE_KEY`: _(optional)_ this is the RSA private key associated 37 | with your GitHub app. You may either pass this in as an environment variable 38 | or store it in `private-key.pem` in this repository's root. 39 | * `APP_BASE_URL`: this is the public hosted URL for the keymap editor static 40 | application (e.g. `https://.github.io/keymap-editor` if using 41 | GitHub pages). Note that this app should have been built with 42 | `ENABLE_GITHUB=true`. 43 | 44 | **Warning** committing any of these values to a git repository is strongly 45 | discouraged. For local development you may create `.env` and `private-key.pem` 46 | and these files will be ignored by default. When deploying the application your 47 | cloud provider (e.g. Heroku) should provide a way to securely define the needed 48 | environment variables. 49 | 50 | ## Auth Flow 51 | 52 | When first authenticating a user to GitHub the following browser flows occur: 53 | 54 | 1. Web app redirects browser to `https://$api/github/authorize` 55 | 2. API redirects browser to GitHub auth URL with a callback 56 | * Here GitHub will prompt the user to log in and to authorize the web app to 57 | make API requests on the user's behalf. 58 | 3. GitHub redirects the browser back to `https://$api/github/authorize` with an 59 | authentication token. 60 | 4. API redirects browser back to the web app with auth token. 61 | * The web app will store the auth token locally and all further requests to 62 | the API will be authenticated. 63 | 5. Web app requests GitHub App installation info 64 | 6. API returns installation info or `null` if the GitHub App is not installed. 65 | * If the GitHub App is installed the flow can end here. 66 | 7. Web app redirects browser to GitHub App Installation URL. 67 | * Here the user is prompted by GitHub to install the application and select 68 | one or more (or all) repositories to enable the app to access and details 69 | what permissions the GitHub app will have. 70 | 8. GitHub redirects browser back to Web app, repeat _Step 6_. 71 | -------------------------------------------------------------------------------- /api/services/github/api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | 3 | const baseUrl = 'https://api.github.com' 4 | 5 | async function request (options={}) { 6 | if (typeof options === 'string') { 7 | options = { 8 | url: options 9 | } 10 | } 11 | 12 | if (options.url.startsWith('/')) { 13 | options.url = `${baseUrl}${options.url}` 14 | } 15 | 16 | options.headers = Object.assign({ 17 | Accept: 'application/vnd.github.v3+json' 18 | }, options.headers) 19 | 20 | if (options.token) { 21 | options.headers.Authorization = `Bearer ${options.token}` 22 | } 23 | 24 | const response = await axios(options) 25 | const limitRemaining = response.headers['x-ratelimit-remaining'] 26 | 27 | if (limitRemaining) { 28 | console.log('GitHub API ratelimit remaining requests:', limitRemaining) 29 | } 30 | 31 | return response 32 | } 33 | 34 | module.exports = { 35 | request 36 | } -------------------------------------------------------------------------------- /api/services/github/auth.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const jwt = require('jsonwebtoken') 4 | 5 | const api = require('./api') 6 | const config = require('../../config') 7 | 8 | const pemPath = path.join(__dirname, '..', '..', '..', 'private-key.pem') 9 | const privateKey = config.GITHUB_APP_PRIVATE_KEY || fs.readFileSync(pemPath) 10 | 11 | function createAppToken () { 12 | return jwt.sign({ iss: config.GITHUB_APP_ID }, privateKey, { 13 | algorithm: 'RS256', 14 | expiresIn: '10m' 15 | }) 16 | } 17 | 18 | function createInstallationToken (installationId) { 19 | const token = createAppToken() 20 | const url = `/app/installations/${installationId}/access_tokens` 21 | return api.request({ url, method: 'POST', token }) 22 | } 23 | 24 | function createOauthFlowUrl () { 25 | const redirectUrl = new URL('https://github.com/login/oauth/authorize') 26 | 27 | redirectUrl.search = new URLSearchParams({ 28 | client_id: config.GITHUB_CLIENT_ID, 29 | redirect_uri: config.GITHUB_OAUTH_CALLBACK_URL, 30 | state: 'foo' 31 | }).toString() 32 | 33 | return redirectUrl.toString() 34 | } 35 | 36 | function createOauthReturnUrl (token) { 37 | const url = new URL(config.APP_BASE_URL) 38 | url.search = new URLSearchParams({ token }).toString() 39 | return url.toString() 40 | } 41 | 42 | function getOauthToken (code) { 43 | return api.request({ 44 | method: 'POST', 45 | url: 'https://github.com/login/oauth/access_token', 46 | headers: { 47 | Accept: 'application/json' 48 | }, 49 | data: { 50 | client_id: config.GITHUB_CLIENT_ID, 51 | client_secret: config.GITHUB_CLIENT_SECRET, 52 | code 53 | } 54 | }) 55 | } 56 | 57 | function getOauthUser (token) { 58 | return api.request({ url: '/user', headers: { Accept: 'application/json' }, token }) 59 | } 60 | 61 | function getUserToken (oauth, user) { 62 | return jwt.sign({ 63 | oauth_access_token: oauth.access_token, 64 | sub: user.login 65 | }, privateKey, { 66 | algorithm: 'RS256' 67 | }) 68 | } 69 | 70 | function verifyUserToken (token) { 71 | return jwt.verify(token, privateKey, { 72 | algorithms: ['RS256'] 73 | }) 74 | } 75 | 76 | module.exports = { 77 | createAppToken, 78 | createInstallationToken, 79 | createOauthFlowUrl, 80 | createOauthReturnUrl, 81 | getOauthToken, 82 | getOauthUser, 83 | getUserToken, 84 | verifyUserToken 85 | } 86 | -------------------------------------------------------------------------------- /api/services/github/files.js: -------------------------------------------------------------------------------- 1 | const api = require('./api') 2 | const auth = require('./auth') 3 | const zmk = require('../zmk') 4 | 5 | const MODE_FILE = '100644' 6 | 7 | class MissingRepoFile extends Error { 8 | constructor(path) { 9 | super() 10 | this.name = 'MissingRepoFile' 11 | this.path = path 12 | this.errors = [`Missing file ${path}`] 13 | } 14 | } 15 | 16 | async function fetchKeyboardFiles (installationId, repository, branch) { 17 | const { data: { token: installationToken } } = await auth.createInstallationToken(installationId) 18 | const { data: info } = await fetchFile(installationToken, repository, 'config/info.json', { raw: true, branch }) 19 | const keymap = await fetchKeymap(installationToken, repository, branch) 20 | const originalCodeKeymap = await findCodeKeymap(installationToken, repository, branch) 21 | return { info, keymap, originalCodeKeymap } 22 | } 23 | 24 | async function fetchKeymap (installationToken, repository, branch) { 25 | try { 26 | const { data : keymap } = await fetchFile(installationToken, repository, 'config/keymap.json', { raw: true, branch }) 27 | return keymap 28 | } catch (err) { 29 | if (err instanceof MissingRepoFile) { 30 | return { 31 | keyboard: 'unknown', 32 | keymap: 'unknown', 33 | layout: 'unknown', 34 | layer_names: ['default'], 35 | layers: [[]] 36 | } 37 | } else { 38 | throw err 39 | } 40 | } 41 | } 42 | 43 | async function fetchFile (installationToken, repository, path, options = {}) { 44 | const { raw = false, branch = null } = options 45 | const url = `/repos/${repository}/contents/${path}` 46 | const params = {} 47 | 48 | if (branch) { 49 | params.ref = branch 50 | } 51 | 52 | const headers = { Accept: raw ? 'application/vnd.github.v3.raw' : 'application/json' } 53 | try { 54 | return await api.request({ url, headers, params, token: installationToken }) 55 | } catch (err) { 56 | if (err.response?.status === 404) { 57 | throw new MissingRepoFile(path) 58 | } 59 | } 60 | } 61 | 62 | async function findCodeKeymap (installationToken, repository, branch) { 63 | // Assume that the relevant files are under `config/` and not a complicated 64 | // directory structure, and that there are fewer than 1000 files in this path 65 | // (a limitation of GitHub's repo contents API). 66 | const { data: directory } = await fetchFile(installationToken, repository, 'config', { branch }) 67 | const originalCodeKeymap = directory.find(file => file.name.toLowerCase().endsWith('.keymap')) 68 | 69 | if (!originalCodeKeymap) { 70 | throw new MissingRepoFile('config/*.keymap') 71 | } 72 | 73 | return originalCodeKeymap 74 | } 75 | 76 | async function findCodeKeymapTemplate (installationToken, repository, branch) { 77 | // Assume that the relevant files are under `config/` and not a complicated 78 | // directory structure, and that there are fewer than 1000 files in this path 79 | // (a limitation of GitHub's repo contents API). 80 | const { data: directory } = await fetchFile(installationToken, repository, 'config', { branch }) 81 | const template = directory.find(file => file.name.toLowerCase().endsWith('.keymap.template')) 82 | 83 | if (template) { 84 | const { data: content } = await fetchFile(installationToken, repository, template.path, { branch, raw: true }) 85 | return content 86 | } 87 | } 88 | 89 | async function commitChanges (installationId, repository, branch, layout, keymap) { 90 | const { data: { token: installationToken } } = await auth.createInstallationToken(installationId) 91 | const template = await findCodeKeymapTemplate(installationToken, repository, branch) 92 | 93 | const generatedKeymap = zmk.generateKeymap(layout, keymap, template) 94 | 95 | const originalCodeKeymap = await findCodeKeymap(installationToken, repository, branch) 96 | const { data: {sha, commit} } = await api.request({ url: `/repos/${repository}/commits/${branch}`, token: installationToken }) 97 | 98 | const { data: { sha: newTreeSha } } = await api.request({ 99 | url: `/repos/${repository}/git/trees`, 100 | method: 'POST', 101 | token: installationToken, 102 | data: { 103 | base_tree: commit.tree.sha, 104 | tree: [ 105 | { 106 | path: originalCodeKeymap.path, 107 | mode: MODE_FILE, 108 | type: 'blob', 109 | content: generatedKeymap.code 110 | }, 111 | { 112 | path: 'config/keymap.json', 113 | mode: MODE_FILE, 114 | type: 'blob', 115 | content: generatedKeymap.json 116 | } 117 | ] 118 | } 119 | }) 120 | 121 | const { data: { sha: newSha } } = await api.request({ 122 | url: `/repos/${repository}/git/commits`, 123 | method: 'POST', 124 | token: installationToken, 125 | data: { 126 | tree: newTreeSha, 127 | message: 'Updated keymap', 128 | parents: [sha] 129 | } 130 | }) 131 | 132 | await api.request({ 133 | url: `/repos/${repository}/git/refs/heads/${branch}`, 134 | method: 'PATCH', 135 | token: installationToken, 136 | data: { 137 | sha: newSha 138 | } 139 | }) 140 | } 141 | 142 | module.exports = { 143 | MissingRepoFile, 144 | fetchKeyboardFiles, 145 | findCodeKeymap, 146 | commitChanges 147 | } 148 | -------------------------------------------------------------------------------- /api/services/github/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | createOauthFlowUrl, 3 | createOauthReturnUrl, 4 | getOauthToken, 5 | getOauthUser, 6 | getUserToken, 7 | verifyUserToken 8 | } = require('./auth') 9 | 10 | const { 11 | fetchInstallation, 12 | fetchInstallationRepos, 13 | fetchRepoBranches 14 | } = require('./installations') 15 | 16 | const { 17 | InvalidRepoError, 18 | fetchKeyboardFiles, 19 | commitChanges 20 | } = require('./files') 21 | 22 | module.exports = { 23 | createOauthFlowUrl, 24 | createOauthReturnUrl, 25 | getOauthToken, 26 | getOauthUser, 27 | getUserToken, 28 | verifyUserToken, 29 | fetchInstallation, 30 | fetchInstallationRepos, 31 | fetchRepoBranches, 32 | InvalidRepoError, 33 | fetchKeyboardFiles, 34 | commitChanges 35 | } 36 | -------------------------------------------------------------------------------- /api/services/github/installations.js: -------------------------------------------------------------------------------- 1 | const linkHeader = require('http-link-header') 2 | 3 | const api = require('./api') 4 | const { createInstallationToken } = require('./auth') 5 | 6 | async function fetchInstallations (userToken) { 7 | const url = '/user/installations' 8 | const { data: { installations } } = await api.request({ url, token: userToken }) 9 | const active = installations.filter(installation => !installation.suspended_at) 10 | 11 | return active 12 | } 13 | 14 | async function fetchInstallationRepos (userToken) { 15 | const repositories = [] 16 | const installations = await fetchInstallations(userToken) 17 | const repoInstallationMap = {} 18 | 19 | for (let installation of installations) { 20 | const { data: { token } } = await createInstallationToken(installation.id) 21 | 22 | let url = `/installation/repositories` 23 | while (url) { 24 | const { headers, data } = await api.request({ url, token }) 25 | const paging = linkHeader.parse(headers.link || '') 26 | 27 | repositories.push(...data.repositories) 28 | for (let repo of data.repositories) { 29 | repoInstallationMap[repo.full_name] = installation.id 30 | } 31 | 32 | url = paging.get('rel', 'next')?.[0]?.uri 33 | } 34 | } 35 | 36 | return { 37 | installations, 38 | repositories, 39 | repoInstallationMap 40 | } 41 | } 42 | 43 | async function fetchRepoBranches (installationToken, repo) { 44 | const initialPage = `/repos/${repo}/branches` 45 | const branches = [] 46 | 47 | let url = initialPage 48 | while (url) { 49 | const { headers, data } = await api.request({ url, token: installationToken }) 50 | const paging = linkHeader.parse(headers.link || '') 51 | branches.push(...data) 52 | url = paging.get('rel', 'next')?.[0]?.uri 53 | } 54 | 55 | return branches 56 | } 57 | 58 | module.exports = { 59 | fetchInstallations, 60 | fetchInstallationRepos, 61 | fetchRepoBranches 62 | } 63 | -------------------------------------------------------------------------------- /api/services/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/api/services/index.js -------------------------------------------------------------------------------- /api/services/zmk/data/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 The ZMK Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /api/services/zmk/data/zmk-behaviors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "code": "&bl", 4 | "name": "Backlight", 5 | "includes": ["#include "], 6 | "params": ["command"], 7 | "commands": [{ 8 | "code": "BL_ON", 9 | "description": "Turn on backlight" 10 | }, { 11 | "code": "BL_OFF", 12 | "description": "Turn off backlight" 13 | }, { 14 | "code": "BL_TOG", 15 | "description": "Toggle backlight on and off" 16 | }, { 17 | "code": "BL_INC", 18 | "description": "Increase brightness" 19 | }, { 20 | "code": "BL_DEC", 21 | "description": "Decrease brightness" 22 | }, { 23 | "code": "BL_CYCLE", 24 | "description": "Cycle brightness" 25 | }, { 26 | "code": "BL_SET", 27 | "description": "Set a specific brightness", 28 | "additionalParams": [{ 29 | "name": "brightness", 30 | "type": "integer", 31 | "enum": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] 32 | }] 33 | }] 34 | }, 35 | { 36 | "code": "&bt", 37 | "name": "Bluetooth", 38 | "params": ["command"], 39 | "includes": ["#include "], 40 | "commands": [{ 41 | "code": "BT_CLR", 42 | "description": "Clear bond information between the keyboard and host for the selected profile." 43 | }, { 44 | "code": "BT_NXT", 45 | "description": "Switch to the next profile, cycling through to the first one when the end is reached." 46 | }, { 47 | "code": "BT_PRV", 48 | "description": "Switch to the previous profile, cycling through to the last one when the beginning is reached." 49 | }, { 50 | "code": "BT_SEL", 51 | "description": "Select the 0-indexed profile by number. Please note: this definition must include a number as an argument in the keymap to work correctly. eg. BT_SEL 0", 52 | "additionalParams": [{ 53 | "name": "index", 54 | "type": "integer", 55 | "enum": [0, 1, 2, 3, 4] 56 | }] 57 | }] 58 | }, 59 | { 60 | "code": "&caps_word", 61 | "name": "Caps Word", 62 | "params": [] 63 | }, 64 | { 65 | "code": "&kp", 66 | "name": "Key Press", 67 | "params": ["code"], 68 | "includes": ["#include "] 69 | }, 70 | { 71 | "code": "&key_repeat", 72 | "name": "Key Repeat", 73 | "params": [] 74 | }, 75 | { 76 | "code": "<", 77 | "name": "Layer Tap", 78 | "params": ["layer", "code"] 79 | }, 80 | { 81 | "code": "&mo", 82 | "name": "Momentary Layer", 83 | "params": ["layer"] 84 | }, 85 | { 86 | "code": "&mt", 87 | "name": "Mod Tap", 88 | "params": ["mod", "code"] 89 | }, 90 | { 91 | "code": "&out", 92 | "name": "Output selection", 93 | "params": ["command"], 94 | "includes": ["#include "], 95 | "commands": [{ 96 | "code": "OUT_BLE", 97 | "description": "Prefer sending to USB" 98 | }, { 99 | "code": "OUT_USB", 100 | "description": "Prefer sending to the current bluetooth profile" 101 | }, { 102 | "code": "OUT_TOG", 103 | "description": "Toggle between USB and BLE" 104 | }] 105 | }, 106 | { 107 | "code": "&sk", 108 | "name": "Sticky Key", 109 | "params": ["code"] 110 | }, 111 | { 112 | "code": "&sl", 113 | "name": "Sticky Layer", 114 | "params": ["layer"] 115 | }, 116 | { 117 | "code": "&to", 118 | "name": "To Layer", 119 | "params": ["layer"] 120 | }, 121 | { 122 | "code": "&tog", 123 | "name": "Toggle Layer", 124 | "params": ["layer"] 125 | }, 126 | { 127 | "code": "&reset", 128 | "name": "Reset", 129 | "params": [] 130 | }, 131 | { 132 | "code": "&bootloader", 133 | "name": "Bootloader", 134 | "params": [] 135 | }, 136 | { 137 | "code": "&rgb_ug", 138 | "name": "RGB underglow control", 139 | "params": ["command"], 140 | "includes": ["#include "], 141 | "commands": [{ 142 | "code": "RGB_TOG", 143 | "description": "Toggles the RGB feature on and off" 144 | }, { 145 | "code": "RGB_HUI", 146 | "description": "Increases the hue of the RGB feature" 147 | }, { 148 | "code": "RGB_HUD", 149 | "description": "Decreases the hue of the RGB feature" 150 | }, { 151 | "code": "RGB_SAI", 152 | "description": "Increases the saturation of the RGB feature" 153 | }, { 154 | "code": "RGB_SAD", 155 | "description": "Decreases the saturation of the RGB feature" 156 | }, { 157 | "code": "RGB_BRI", 158 | "description": "Increases the brightness of the RGB feature" 159 | }, { 160 | "code": "RGB_BRD", 161 | "description": "Decreases the brightness of the RGB feature" 162 | }, { 163 | "code": "RGB_SPI", 164 | "description": "Increases the speed of the RGB feature effect's animation" 165 | }, { 166 | "code": "RGB_SPD", 167 | "description": "Decreases the speed of the RGB feature effect's animation" 168 | }, { 169 | "code": "RGB_EFF", 170 | "description": "Cycles the RGB feature's effect forwards" 171 | }, { 172 | "code": "RGB_EFR", 173 | "description": "Cycles the RGB feature's effect reverse" 174 | }] 175 | }, 176 | { 177 | "code": "&ext_power", 178 | "name": "External power management", 179 | "params": ["command"], 180 | "includes": ["#include "], 181 | "commands": [{ 182 | "code": "EP_ON", 183 | "description": "Enable the external power" 184 | }, { 185 | "code": "EP_OFF", 186 | "description": "Disable the external power" 187 | }, { 188 | "code": "EP_TOG", 189 | "description": "Toggle the external power" 190 | }] 191 | }, 192 | { 193 | "code": "&trans", 194 | "name": "Transparent", 195 | "params": [] 196 | }, 197 | { 198 | "code": "&none", 199 | "name": "None", 200 | "params": [] 201 | } 202 | ] 203 | -------------------------------------------------------------------------------- /api/services/zmk/defaults.js: -------------------------------------------------------------------------------- 1 | const keymapTemplate = ` 2 | /* 3 | * Copyright (c) 2020 The ZMK Contributors 4 | * 5 | * SPDX-License-Identifier: MIT 6 | */ 7 | 8 | 9 | /* THIS FILE WAS GENERATED! 10 | * 11 | * This file was generated automatically. You may or may not want to 12 | * edit it directly. 13 | */ 14 | 15 | #include 16 | {{behaviour_includes}} 17 | 18 | / { 19 | keymap { 20 | compatible = "zmk,keymap"; 21 | 22 | {{rendered_layers}} 23 | }; 24 | }; 25 | ` 26 | 27 | module.exports = { 28 | keymapTemplate 29 | } 30 | -------------------------------------------------------------------------------- /api/services/zmk/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | parseKeyBinding, 3 | generateKeymap 4 | } = require('./keymap') 5 | 6 | const { 7 | loadBehaviors, 8 | loadKeycodes, 9 | loadLayout, 10 | loadKeymap, 11 | exportKeymap 12 | } = require('./local-source') 13 | 14 | module.exports = { 15 | parseKeyBinding, 16 | generateKeymap, 17 | loadBehaviors, 18 | loadKeycodes, 19 | loadLayout, 20 | loadKeymap, 21 | exportKeymap 22 | } 23 | -------------------------------------------------------------------------------- /api/services/zmk/keymap.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const filter = require('lodash/filter') 4 | const flatten = require('lodash/flatten') 5 | const get = require('lodash/get') 6 | const keyBy = require('lodash/keyBy') 7 | const map = require('lodash/map') 8 | const uniq = require('lodash/uniq') 9 | 10 | const { renderTable } = require('./layout') 11 | const defaults = require('./defaults') 12 | 13 | class KeymapValidationError extends Error { 14 | constructor (errors) { 15 | super() 16 | this.name = 'KeymapValidationError' 17 | this.errors = errors 18 | } 19 | } 20 | 21 | const behaviours = JSON.parse(fs.readFileSync(path.join(__dirname, 'data/zmk-behaviors.json'))) 22 | const behavioursByBind = keyBy(behaviours, 'code') 23 | 24 | function encodeBindValue(parsed) { 25 | const params = (parsed.params || []).map(encodeBindValue) 26 | const paramString = params.length > 0 ? `(${params.join(',')})` : '' 27 | return parsed.value + paramString 28 | } 29 | 30 | function encodeKeyBinding(parsed) { 31 | const { value, params } = parsed 32 | 33 | return `${value} ${params.map(encodeBindValue).join(' ')}`.trim() 34 | } 35 | 36 | function encodeKeymap(parsedKeymap) { 37 | return Object.assign({}, parsedKeymap, { 38 | layers: parsedKeymap.layers.map(layer => layer.map(encodeKeyBinding)) 39 | }) 40 | } 41 | 42 | function getBehavioursUsed(keymap) { 43 | const keybinds = flatten(keymap.layers) 44 | return uniq(map(keybinds, 'value')) 45 | } 46 | 47 | /** 48 | * Parse a bind string into a tree of values and parameters 49 | * @param {String} binding 50 | * @returns {Object} 51 | */ 52 | function parseKeyBinding(binding) { 53 | const paramsPattern = /\((.+)\)/ 54 | 55 | function parse(code) { 56 | const value = code.replace(paramsPattern, '') 57 | const params = get(code.match(paramsPattern), '[1]', '').split(',') 58 | .map(s => s.trim()) 59 | .filter(s => s.length > 0) 60 | .map(parse) 61 | 62 | return { value, params } 63 | } 64 | 65 | const value = binding.match(/^(&.+?)\b/)[1] 66 | const params = filter(binding.replace(/^&.+?\b\s*/, '') 67 | .split(' ')) 68 | .map(parse) 69 | 70 | return { value, params } 71 | } 72 | 73 | function parseKeymap (keymap) { 74 | return Object.assign({}, keymap, { 75 | layers: keymap.layers.map(layer => { 76 | return layer.map(parseKeyBinding) 77 | }) 78 | }) 79 | } 80 | 81 | function generateKeymap (layout, keymap, template) { 82 | const encoded = encodeKeymap(keymap) 83 | return { 84 | code: generateKeymapCode(layout, keymap, encoded, template || defaults.keymapTemplate), 85 | json: generateKeymapJSON(layout, keymap, encoded) 86 | } 87 | } 88 | 89 | function renderTemplate(template, params) { 90 | const includesPattern = /\{\{\s*behaviour_includes\s*\}\}/ 91 | const layersPattern = /\{\{\s*rendered_layers\s*\}\}/ 92 | 93 | const renderedLayers = params.layers.map((layer, i) => { 94 | const name = i === 0 ? 'default_layer' : `layer_${params.layerNames[i] || i}` 95 | const rendered = renderTable(params.layout, layer, { 96 | linePrefix: '', 97 | columnSeparator: ' ' 98 | }) 99 | 100 | return ` 101 | ${name.replace(/[^a-zA-Z0-9_]/g, '_')} { 102 | bindings = < 103 | ${rendered} 104 | >; 105 | }; 106 | ` 107 | }) 108 | 109 | return template 110 | .replace(includesPattern, params.behaviourHeaders.join('\n')) 111 | .replace(layersPattern, renderedLayers.join('')) 112 | } 113 | 114 | function generateKeymapCode (layout, keymap, encoded, template) { 115 | const { layer_names: names = [] } = keymap 116 | const behaviourHeaders = flatten(getBehavioursUsed(keymap).map( 117 | bind => get(behavioursByBind, [bind, 'includes'], []) 118 | )) 119 | 120 | return renderTemplate(template, { 121 | layout, 122 | behaviourHeaders, 123 | layers: encoded.layers, 124 | layerNames: names 125 | }) 126 | } 127 | 128 | function generateKeymapJSON (layout, keymap, encoded) { 129 | const base = JSON.stringify(Object.assign({}, encoded, { layers: null }), null, 2) 130 | const layers = encoded.layers.map(layer => { 131 | const rendered = renderTable(layout, layer, { 132 | useQuotes: true, 133 | linePrefix: ' ' 134 | }) 135 | 136 | return `[\n${rendered}\n ]` 137 | }) 138 | 139 | return base.replace('"layers": null', `"layers": [\n ${layers.join(', ')}\n ]`) 140 | } 141 | 142 | function validateKeymapJson(keymap) { 143 | const errors = [] 144 | 145 | if (typeof keymap !== 'object' || keymap === null) { 146 | errors.push('keymap.json root must be an object') 147 | } else if (!Array.isArray(keymap.layers)) { 148 | errors.push('keymap must include "layers" array') 149 | } else { 150 | for (let i in keymap.layers) { 151 | const layer = keymap.layers[i] 152 | 153 | if (!Array.isArray(layer)) { 154 | errors.push(`Layer at layers[${i}] must be an array`) 155 | } else { 156 | for (let j in layer) { 157 | const key = layer[j] 158 | const keyPath = `layers[${i}][${j}]` 159 | 160 | if (typeof key !== 'string') { 161 | errors.push(`Value at "${keyPath}" must be a string`) 162 | } else { 163 | const bind = key.match(/^&.+?\b/) 164 | if (!(bind && bind[0] in behavioursByBind)) { 165 | errors.push(`Key bind at "${keyPath}" has invalid behaviour`) 166 | } 167 | } 168 | 169 | // TODO: validate remaining bind parameters 170 | } 171 | } 172 | } 173 | } 174 | 175 | if (errors.length) { 176 | throw new KeymapValidationError(errors) 177 | } 178 | } 179 | 180 | module.exports = { 181 | KeymapValidationError, 182 | encodeKeymap, 183 | parseKeymap, 184 | generateKeymap, 185 | validateKeymapJson 186 | } 187 | -------------------------------------------------------------------------------- /api/services/zmk/layout.js: -------------------------------------------------------------------------------- 1 | const isNumber = require('lodash/isNumber') 2 | 3 | class InfoValidationError extends Error { 4 | constructor (errors) { 5 | super() 6 | this.name = 'InfoValidationError' 7 | this.errors = errors 8 | } 9 | } 10 | 11 | function renderTable (layout, layer, opts={}) { 12 | const { 13 | useQuotes = false, 14 | linePrefix = '', 15 | columnSeparator = ',' 16 | } = opts 17 | const minWidth = useQuotes ? 9 : 7 18 | const table = layer.reduce((map, code, i) => { 19 | // TODO: this would be better as a loop over `layout`, checking for a 20 | // matching element in the `layer` array. Or, alternatively, an earlier 21 | // validation that asserts each layer is equal in length to the number of 22 | // keys in the layout. 23 | if (layout[i]) { 24 | const { row = 0, col } = layout[i] 25 | map[row] = map[row] || [] 26 | map[row][col || map[row].length] = code 27 | } 28 | 29 | return map 30 | }, []) 31 | 32 | const columns = Math.max(...table.map(row => row.length)) 33 | const columnIndices = '.'.repeat(columns-1).split('.').map((_, i) => i) 34 | const columnWidths = columnIndices.map(i => Math.max( 35 | ...table.map(row => ( 36 | (row[i] || []).length 37 | + columnSeparator.length 38 | + (useQuotes ? 2 : 0) // wrapping with quotes adds 2 characters 39 | )) 40 | )) 41 | 42 | return table.map((row, rowIndex) => { 43 | const isLastRow = rowIndex === table.length - 1 44 | return linePrefix + columnIndices.map(i => { 45 | const noMoreValues = row.slice(i).every(col => col === undefined) 46 | const noFollowingValues = row.slice(i+1).every(col => col === undefined) 47 | const padding = Math.max(minWidth, columnWidths[i]) 48 | 49 | if (noMoreValues) return '' 50 | if (!row[i]) return ' '.repeat(padding + 1) 51 | const column = (useQuotes ? `"${row[i]}"` : row[i]).padStart(padding) 52 | const suffix = (isLastRow && noFollowingValues) ? '' : columnSeparator 53 | return column + suffix 54 | }).join('').replace(/\s+$/, '') 55 | }).join('\n') 56 | } 57 | 58 | function validateInfoJson(info) { 59 | const errors = [] 60 | 61 | if (typeof info !== 'object' || info === null) { 62 | errors.push('info.json root must be an object') 63 | } else if (!info.layouts) { 64 | errors.push('info must define "layouts"') 65 | } else if (typeof info.layouts !== 'object' || info.layouts === null) { 66 | errors.push('layouts must be an object') 67 | } else if (Object.values(info.layouts).length === 0) { 68 | errors.push('layouts must define at least one layout') 69 | } else { 70 | for (let name in info.layouts) { 71 | const layout = info.layouts[name] 72 | if (typeof layout !== 'object' || layout === null) { 73 | errors.push(`layout ${name} must be an object`) 74 | } else if (!Array.isArray(layout.layout)) { 75 | errors.push(`layout ${name} must define "layout" array`) 76 | } else { 77 | const anyKeyHasPosition = layout.layout.some(key => ( 78 | key?.row !== undefined || 79 | key?.col !== undefined 80 | )) 81 | 82 | for (let i in layout.layout) { 83 | const key = layout.layout[i] 84 | const keyPath = `layouts[${name}].layout[${i}]` 85 | 86 | if (typeof key !== 'object' || key === null) { 87 | errors.push(`Key definition at ${keyPath} must be an object`) 88 | } else { 89 | const optionalNumberProps = ['u', 'h', 'r', 'rx', 'ry'] 90 | if (!isNumber(key.x)) { 91 | errors.push(`Key definition at ${keyPath} must include "x" position`) 92 | } 93 | if (!isNumber(key.y)) { 94 | errors.push(`Key definition at ${keyPath} must include "y" position`) 95 | } 96 | for (let prop of optionalNumberProps) { 97 | if (prop in key && !isNumber(key[prop])) { 98 | errors.push(`Key definition at ${keyPath} optional "${prop}" must be number`) 99 | } 100 | } 101 | for (let prop of ['row', 'col']) { 102 | if (anyKeyHasPosition && !(prop in key)) { 103 | errors.push(`Key definition at ${keyPath} is missing "${prop}"`) 104 | } else if (prop in key && (!Number.isInteger(key[prop]) || key[prop] < 0)) { 105 | errors.push(`Key definition at ${keyPath} "${prop}" must be a non-negative integer`) 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | if (errors.length) { 115 | throw new InfoValidationError(errors) 116 | } 117 | } 118 | 119 | module.exports = { 120 | InfoValidationError, 121 | renderTable, 122 | validateInfoJson 123 | } 124 | -------------------------------------------------------------------------------- /api/services/zmk/local-source.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const { parseKeymap } = require('./keymap') 5 | 6 | const ZMK_PATH = path.join(__dirname, '..', '..', '..', 'zmk-config') 7 | const KEYBOARD = 'dactyl' 8 | 9 | const EMPTY_KEYMAP = { 10 | keyboard: 'unknown', 11 | keymap: 'unknown', 12 | layout: 'unknown', 13 | layer_names: ['default'], 14 | layers: [[]] 15 | } 16 | 17 | function loadBehaviors() { 18 | return JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'zmk-behaviors.json'))) 19 | } 20 | 21 | function loadKeycodes() { 22 | return JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'zmk-keycodes.json'))) 23 | } 24 | 25 | function loadLayout (layout = 'LAYOUT') { 26 | const layoutPath = path.join(ZMK_PATH, 'config', 'info.json') 27 | return JSON.parse(fs.readFileSync(layoutPath)).layouts[layout].layout 28 | } 29 | 30 | function loadKeymap () { 31 | const keymapPath = path.join(ZMK_PATH, 'config', 'keymap.json') 32 | const keymapContent = fs.existsSync(keymapPath) 33 | ? JSON.parse(fs.readFileSync(keymapPath)) 34 | : EMPTY_KEYMAP 35 | 36 | return parseKeymap(keymapContent) 37 | } 38 | 39 | function findKeymapFile () { 40 | const files = fs.readdirSync(path.join(ZMK_PATH, 'config')) 41 | return files.find(file => file.endsWith('.keymap')) 42 | } 43 | 44 | function exportKeymap (generatedKeymap, flash, callback) { 45 | const keymapPath = path.join(ZMK_PATH, 'config') 46 | const keymapFile = findKeymapFile() 47 | 48 | fs.existsSync(keymapPath) || fs.mkdirSync(keymapPath) 49 | fs.writeFileSync(path.join(keymapPath, 'keymap.json'), generatedKeymap.json) 50 | fs.writeFileSync(path.join(keymapPath, keymapFile), generatedKeymap.code) 51 | 52 | // Note: This isn't really helpful. In the QMK version I had this actually 53 | // calling `make` and piping the output in realtime but setting up a ZMK dev 54 | // environment proved to be more complex than I had patience for, so for now 55 | // I'm writing changes to a zmk-config repo and counting on the predefined 56 | // GitHub action to actually compile. 57 | return childProcess.execFile('git', ['status'], { cwd: ZMK_PATH }, callback) 58 | } 59 | 60 | module.exports = { 61 | loadBehaviors, 62 | loadKeycodes, 63 | loadLayout, 64 | loadKeymap, 65 | exportKeymap 66 | } 67 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # Keymap Editor - Web Application 2 | 3 | This is a single page application currently written in React to integrate with 4 | the Keymap Editor API. 5 | 6 | It handles keyboard selection and rendering of parsed keymap data into a visual 7 | editor. This application is _aware_ of some of the particulars of ZMK, but it 8 | receives key bindings already parsed into a tree of values and parameters. 9 | 10 | ## Building 11 | 12 | The easiest way to use this is the [hosted version](https://nickcoutsos.github.io/keymap-editor). 13 | The second easiest is locally, served up via the API itself (in the repo root, 14 | run `npm run dev` and open `http://localhost:8080` in your browser). 15 | 16 | If you must deploy this app to the web then you'll need to take care of building 17 | it. This requires some configuration, as seen in the [config module](./config.js). 18 | 19 | All configuration is provided via environment variables. 20 | 21 | Variable | Description 22 | ----------------------------|------------- 23 | `REACT_APP_API_BASE_URL` | Fully qualified publicly accessible URL of the backend API. 24 | `REACT_APP_APP_BASE_URL` | Fully qualified publicly accessible URL of _this_ app. 25 | `REACT_APP_GITHUB_APP_NAME` | The app name (slug?) of the GitHub app integration (only required if using with GitHub). 26 | `REACT_APP_ENABLE_GITHUB` | Whether to enable fetching keyboard data from GitHub. Default is false, values `"1"`, `"on"`, `"yes"`, `"true"` are interpreted as `true`. 27 | `REACT_APP_ENABLE_LOCAL` | Whether to enable fetching keyboard data from locally. Default is false, values `"1"`, `"on"`, `"yes"`, `"true"` are interpreted as `true`. 28 | 29 | _Note: choosing to use the GitHub integration in your own environment isn't a 30 | matter of flipping a switch, you will need to set up your own app in GitHub and 31 | configure your API accordingly._ 32 | 33 | With these set you can run the npm build script, e.g. 34 | 35 | ```bash 36 | export REACT_APP_API_BASE_URL=... 37 | export REACT_APP_APP_BASE_URL=... 38 | export REACT_APP_GITHUB_APP_NAME=... 39 | export REACT_APP_ENABLE_GITHUB=... 40 | export REACT_APP_ENABLE_LOCAL=... 41 | npm run build 42 | ``` 43 | 44 | _(make sure you're in this directory, not the repository root!)_ 45 | 46 | This will have webpack produce bundles in the `build/` directory which you can 47 | deploy however you like. 48 | 49 | ### Deploying to GitHub Pages 50 | 51 | On your GitHub repository's settings page, select _Pages_ in the sidebar. Pick a 52 | branch you want to serve the app from (I use `pages`) and choose the `/ (root)` 53 | directory. Check out that branch (I have another working repository locally for 54 | this) locally, or make a new orphaned branch if such a branch doesn't exist, and 55 | copy the contents of `build/` to it. Commit and push to the GitHub remote. 56 | 57 | If you're not familiar with this it's worth reading up on the [GitHub Pages docs](https://docs.github.com/en/pages). -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-free": "^6.1.1", 7 | "@testing-library/jest-dom": "^5.16.4", 8 | "@testing-library/react": "^13.0.1", 9 | "@testing-library/user-event": "^13.5.0", 10 | "axios": "^0.26.1", 11 | "eventemitter3": "^4.0.7", 12 | "fuzzysort": "^1.2.1", 13 | "lodash": "^4.17.21", 14 | "prop-types": "^15.8.1", 15 | "react": "^18.0.0", 16 | "react-dom": "^18.0.0", 17 | "react-scripts": "5.0.1", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "build:production": "env-cmd -f .env.production npm run build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "env-cmd": "^10.1.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/public/editor-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/app/public/editor-icon.png -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ZMK Keymap Editor 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dark-red: #910e0e; 3 | --dark-blue: #6d99c6; 4 | --selection: rgb(60, 179, 113); 5 | --hover-selection: rgba(60, 179, 113, 0.85); 6 | } 7 | html { 8 | font-family: Quicksand, avenir, sans-serif; 9 | } 10 | html, body { 11 | width: 100vw; 12 | height: 100vh; 13 | overflow: auto; 14 | padding: 0; 15 | margin: 0; 16 | } 17 | 18 | #actions { 19 | position: absolute; 20 | bottom: 5px; 21 | right: 20px; 22 | } 23 | 24 | #actions button { 25 | cursor: pointer; 26 | background-color: var(--hover-selection); 27 | color: white; 28 | 29 | font-size: 16px; 30 | border: none; 31 | border-radius: 5px; 32 | padding: 5px; 33 | margin: 2px; 34 | } 35 | 36 | #actions button[disabled] { 37 | background-color: #ccc; 38 | cursor: not-allowed; 39 | } 40 | 41 | .github-link { 42 | display: inline-block; 43 | position: absolute; 44 | z-index: 100; 45 | bottom: 5px; 46 | left: 5px; 47 | font-size: 110%; 48 | font-style: italic; 49 | background-color: white; 50 | border-radius: 20px; 51 | padding: 5px 10px; 52 | text-decoration: none; 53 | 54 | color: royalblue; 55 | } -------------------------------------------------------------------------------- /app/src/App.js: -------------------------------------------------------------------------------- 1 | import '@fortawesome/fontawesome-free/css/all.css' 2 | import keyBy from 'lodash/keyBy' 3 | import { useMemo, useState } from 'react' 4 | 5 | import * as config from './config' 6 | import './App.css'; 7 | import { DefinitionsContext } from './providers' 8 | import { loadKeycodes } from './keycodes' 9 | import { loadBehaviours } from './api' 10 | import KeyboardPicker from './Pickers/KeyboardPicker'; 11 | import Spinner from './Common/Spinner'; 12 | import Keyboard from './Keyboard/Keyboard' 13 | import GitHubLink from './GitHubLink' 14 | import Loader from './Common/Loader' 15 | import github from './Pickers/Github/api' 16 | 17 | function App() { 18 | const [definitions, setDefinitions] = useState(null) 19 | const [source, setSource] = useState(null) 20 | const [sourceOther, setSourceOther] = useState(null) 21 | const [layout, setLayout] = useState(null) 22 | const [keymap, setKeymap] = useState(null) 23 | const [editingKeymap, setEditingKeymap] = useState(null) 24 | const [saving, setSaving] = useState(false) 25 | 26 | function handleCompile() { 27 | fetch(`${config.apiBaseUrl}/keymap`, { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | body: JSON.stringify(editingKeymap || keymap) 33 | }) 34 | } 35 | 36 | const handleCommitChanges = useMemo(() => function() { 37 | const { repository, branch } = sourceOther.github 38 | 39 | ;(async function () { 40 | setSaving(true) 41 | await github.commitChanges(repository, branch, layout, editingKeymap) 42 | setSaving(false) 43 | 44 | setKeymap(editingKeymap) 45 | setEditingKeymap(null) 46 | })() 47 | }, [ 48 | layout, 49 | editingKeymap, 50 | sourceOther, 51 | setSaving, 52 | setKeymap, 53 | setEditingKeymap 54 | ]) 55 | 56 | const handleKeyboardSelected = useMemo(() => function(event) { 57 | const { source, layout, keymap, ...other } = event 58 | 59 | setSource(source) 60 | setSourceOther(other) 61 | setLayout(layout) 62 | setKeymap(keymap) 63 | setEditingKeymap(null) 64 | }, [ 65 | setSource, 66 | setSourceOther, 67 | setLayout, 68 | setKeymap, 69 | setEditingKeymap 70 | ]) 71 | 72 | const initialize = useMemo(() => { 73 | return async function () { 74 | const [keycodes, behaviours] = await Promise.all([ 75 | loadKeycodes(), 76 | loadBehaviours() 77 | ]) 78 | 79 | keycodes.indexed = keyBy(keycodes, 'code') 80 | behaviours.indexed = keyBy(behaviours, 'code') 81 | 82 | setDefinitions({ keycodes, behaviours }) 83 | } 84 | }, [setDefinitions]) 85 | 86 | const handleUpdateKeymap = useMemo(() => function(keymap) { 87 | setEditingKeymap(keymap) 88 | }, [setEditingKeymap]) 89 | 90 | return ( 91 | <> 92 | 93 | 94 |
95 | {source === 'local' && ( 96 | 99 | )} 100 | {source === 'github' && ( 101 | 109 | )} 110 |
111 | 112 | {layout && keymap && ( 113 | 118 | )} 119 | 120 |
121 | 122 | 123 | ); 124 | } 125 | 126 | export default App; 127 | -------------------------------------------------------------------------------- /app/src/Common/DialogBox.js: -------------------------------------------------------------------------------- 1 | const styles = { 2 | dialog: { 3 | backgroundColor: 'white', 4 | padding: '20px 40px', 5 | margin: '40px', 6 | maxWidth: '500px', 7 | boxShadow: '0px 10px 25px rgba(0, 0, 0, 0.4)', 8 | }, 9 | button: { 10 | display: 'block', 11 | margin: '0 auto' 12 | } 13 | } 14 | 15 | export default function DialogBox(props) { 16 | const { dismissText = 'Ok', onDismiss, children } = props 17 | 18 | return ( 19 |
20 | {children} 21 | {dismissText && ( 22 | 25 | )} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/src/Common/Icon.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | const faCollections = { 4 | brands: 'fab', 5 | default: 'fa' 6 | } 7 | 8 | function Icon (props) { 9 | const { name, className, collection, ...iconProps } = props 10 | const groupClass = faCollections[collection] 11 | const iconClass = `fa-${name}` 12 | 13 | return ( 14 | 18 | ) 19 | } 20 | 21 | Icon.propTypes = { 22 | name: PropTypes.string.isRequired, 23 | className: PropTypes.string, 24 | collection: PropTypes.string 25 | } 26 | 27 | Icon.defaultProps = { 28 | collection: 'default', 29 | className: '' 30 | } 31 | 32 | export default Icon 33 | -------------------------------------------------------------------------------- /app/src/Common/IconButton.js: -------------------------------------------------------------------------------- 1 | import Icon from './Icon' 2 | 3 | export default function IconButton({ collection, icon, text, children, onClick }) { 4 | return ( 5 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /app/src/Common/Loader.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { useEffect, useState } from 'react' 3 | 4 | import Modal from './Modal' 5 | import Spinner from './Spinner' 6 | 7 | function Loader(props) { 8 | const { load, delay, children } = props 9 | const [state, setState] = useState({ 10 | loaded: false, 11 | delayed: false, 12 | timeout: null 13 | }) 14 | 15 | useEffect(() => { 16 | clearTimeout(state.timeout) 17 | if (!load) { 18 | return 19 | } 20 | 21 | const timeout = setTimeout(() => { 22 | if (!state.loaded) { 23 | setState({ ...state, timeout: null, delayed: true }) 24 | } 25 | }, delay) 26 | 27 | setState({ 28 | loaded: false, 29 | delayed: false, 30 | timeout 31 | }) 32 | 33 | load().then(() => { 34 | clearTimeout(timeout) 35 | setState({ ...state, timeout: null, loaded: true }) 36 | }) 37 | }, [load]) 38 | 39 | if (state.loaded) { 40 | return children 41 | } else if (!state.delayed) { 42 | return null 43 | } 44 | 45 | return ( 46 | 47 | 48 |

Waiting for API...

49 |
50 |
51 | ) 52 | } 53 | 54 | Loader.propTypes = { 55 | load: PropTypes.func.isRequired, 56 | inline: PropTypes.bool, 57 | delay: PropTypes.number, 58 | } 59 | 60 | Loader.defaultProps = { 61 | inline: false, 62 | delay: 200 63 | } 64 | 65 | export default Loader 66 | -------------------------------------------------------------------------------- /app/src/Common/Modal.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom" 2 | 3 | const styles = { 4 | wrapper: { 5 | position: 'absolute', 6 | top: '0', 7 | left: '0', 8 | width: '100vw', 9 | height: '100vh', 10 | backgroundColor: 'rgba(104, 123, 162, 0.39)', 11 | zIndex: '50', 12 | display: 'flex', 13 | justifyContent: 'center', 14 | alignItems: 'center', 15 | }, 16 | content: { 17 | display: 'block' 18 | } 19 | } 20 | 21 | export default function Modal({ children }) { 22 | return ReactDOM.createPortal( 23 |
24 |
25 | {children} 26 |
27 |
, 28 | document.getElementById('modal-root') 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/src/Common/Selector.js: -------------------------------------------------------------------------------- 1 | const styles = { 2 | selector: { 3 | display: 'inline-block', 4 | width: 'auto', 5 | margin: '5px' 6 | }, 7 | label: { 8 | display: 'block', 9 | width: '100%', 10 | fontSize: '120%', 11 | color: '#555' 12 | } 13 | } 14 | 15 | export default function Selector(props) { 16 | const { id, label, value, choices, onUpdate } = props 17 | const handleSelect = e => { 18 | const index = e.target.value 19 | const choice = choices[index].id 20 | onUpdate(choice) 21 | } 22 | 23 | function index(value) { 24 | const result = choices.findIndex(choice => choice.id === value) 25 | return result === -1 ? '' : result 26 | } 27 | 28 | return ( 29 |
30 | 33 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /app/src/Common/Spinner.js: -------------------------------------------------------------------------------- 1 | import styles from './spinner.module.css' 2 | 3 | export default function Spinner({ children, ...rest }) { 4 | return ( 5 |
6 | 7 | {children} 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/Common/spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | display: inline-block; 3 | text-align: center; 4 | } 5 | .icon { 6 | display: inline-block; 7 | animation: spin 1s linear infinite; 8 | } 9 | 10 | @keyframes spin { 11 | from { transform: rotate(0deg); } 12 | to { transform: rotate(360deg); } 13 | } -------------------------------------------------------------------------------- /app/src/GitHubLink.js: -------------------------------------------------------------------------------- 1 | import Icon from './Common/Icon' 2 | 3 | export default function GitHubLink(props = {}) { 4 | return ( 5 | 11 | /nickcoutsos/keymap-editor 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keyboard.js: -------------------------------------------------------------------------------- 1 | import filter from 'lodash/filter' 2 | import get from 'lodash/get' 3 | import isEmpty from 'lodash/isEmpty' 4 | import keyBy from 'lodash/keyBy' 5 | import times from 'lodash/times' 6 | import PropTypes from 'prop-types' 7 | import { useContext, useMemo, useState } from 'react' 8 | 9 | import KeyboardLayout from './KeyboardLayout' 10 | import LayerSelector from './LayerSelector' 11 | import { getKeyBoundingBox } from '../key-units' 12 | import { DefinitionsContext, SearchContext } from '../providers' 13 | 14 | function Keyboard(props) { 15 | const { layout, keymap, onUpdate } = props 16 | const [activeLayer, setActiveLayer] = useState(0) 17 | const {keycodes, behaviours} = useContext(DefinitionsContext) 18 | 19 | const availableLayers = useMemo(() => isEmpty(keymap) ? [] : ( 20 | keymap.layers.map((_, i) => ({ 21 | code: i, 22 | description: keymap.layer_names[i] || `Layer ${i}` 23 | })) 24 | ), [keymap]) 25 | 26 | const sources = useMemo(() => ({ 27 | kc: keycodes.indexed, 28 | code: keycodes.indexed, 29 | mod: keyBy(filter(keycodes, 'isModifier'), 'code'), 30 | behaviours: behaviours.indexed, 31 | layer: keyBy(availableLayers, 'code') 32 | }), [keycodes, behaviours, availableLayers]) 33 | 34 | // TODO: this may be unnecessary 35 | const isReady = useMemo(() => function() { 36 | return ( 37 | Object.keys(keycodes.indexed).length > 0 && 38 | Object.keys(behaviours.indexed).length > 0 && 39 | get(keymap, 'layers.length', 0) > 0 40 | ) 41 | }, [keycodes, behaviours, keymap]) 42 | 43 | const searchTargets = useMemo(() => { 44 | return { 45 | behaviour: behaviours, 46 | layer: availableLayers, 47 | mod: filter(keycodes, 'isModifier'), 48 | code: keycodes 49 | } 50 | }, [behaviours, keycodes, availableLayers]) 51 | 52 | const getSearchTargets = useMemo(() => function (param, behaviour) { 53 | // Special case for behaviour commands which can dynamically add another 54 | // parameter that isn't defined at the root level of the behaviour. 55 | // Currently this is just `&bt BT_SEL` and is only represented as an enum. 56 | if (param.enum) { 57 | return param.enum.map(v => ({ code: v })) 58 | } 59 | 60 | if (param === 'command') { 61 | return get(sources, ['behaviours', behaviour, 'commands'], []) 62 | } 63 | 64 | if (!searchTargets[param]) { 65 | console.log('cannot find target for', param) 66 | } 67 | 68 | return searchTargets[param] 69 | }, [searchTargets, sources]) 70 | 71 | const boundingBox = useMemo(() => function () { 72 | return layout.map(key => getKeyBoundingBox( 73 | { x: key.x, y: key.y }, 74 | { u: key.u || key.w || 1, h: key.h || 1 }, 75 | { x: key.rx, y: key.ry, a: key.r } 76 | )).reduce(({ x, y }, { max }) => ({ 77 | x: Math.max(x, max.x), 78 | y: Math.max(y, max.y) 79 | }), { x: 0, y: 0 }) 80 | }, [layout]) 81 | 82 | const getWrapperStyle = useMemo(() => function () { 83 | const bbox = boundingBox() 84 | return { 85 | width: `${bbox.x}px`, 86 | height: `${bbox.y}px`, 87 | margin: '0 auto', 88 | padding: '40px' 89 | } 90 | }, [boundingBox]) 91 | 92 | const handleCreateLayer = useMemo(() => function () { 93 | const layer = keymap.layers.length 94 | const binding = '&trans' 95 | const makeKeycode = () => ({ value: binding, params: [] }) 96 | 97 | const newLayer = times(layout.length, makeKeycode) 98 | const updatedLayerNames = [ ...keymap.layer_names, `Layer #${layer}` ] 99 | const layers = [ ...keymap.layers, newLayer ] 100 | 101 | onUpdate({ ...keymap, layer_names: updatedLayerNames, layers }) 102 | }, [keymap, layout, onUpdate]) 103 | 104 | const handleUpdateLayer = useMemo(() => function(layerIndex, updatedLayer) { 105 | const original = keymap.layers 106 | const layers = [ 107 | ...original.slice(0, layerIndex), 108 | updatedLayer, 109 | ...original.slice(layerIndex + 1) 110 | ] 111 | 112 | onUpdate({ ...keymap, layers }) 113 | }, [keymap, onUpdate]) 114 | 115 | const handleRenameLayer = useMemo(() => function (layerName) { 116 | const layer_names = [ 117 | ...keymap.layer_names.slice(0, activeLayer), 118 | layerName, 119 | ...keymap.layer_names.slice(activeLayer + 1) 120 | ] 121 | 122 | onUpdate({ ...keymap, layer_names }) 123 | }, [keymap, activeLayer, onUpdate]) 124 | 125 | const handleDeleteLayer = useMemo(() => function (layerIndex) { 126 | const layer_names = [...keymap.layer_names] 127 | layer_names.splice(layerIndex, 1) 128 | 129 | const layers = [...keymap.layers] 130 | layers.splice(layerIndex, 1) 131 | 132 | if (activeLayer > layers.length - 1) { 133 | setActiveLayer(Math.max(0, layers.length - 1)) 134 | } 135 | 136 | onUpdate({ ...keymap, layers, layer_names }) 137 | }, [keymap, activeLayer, setActiveLayer, onUpdate]) 138 | 139 | return ( 140 | <> 141 | 149 | 150 |
151 | {isReady() && ( 152 | handleUpdateLayer(activeLayer, event)} 157 | /> 158 | )} 159 |
160 |
161 | 162 | ) 163 | } 164 | 165 | Keyboard.propTypes = { 166 | layout: PropTypes.array.isRequired, 167 | keymap: PropTypes.object.isRequired, 168 | onUpdate: PropTypes.func.isRequired 169 | } 170 | 171 | export default Keyboard 172 | -------------------------------------------------------------------------------- /app/src/Keyboard/KeyboardLayout.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import PropTypes from 'prop-types' 3 | import { useMemo } from 'react' 4 | 5 | import Key from './Keys/Key' 6 | 7 | const position = key => pick(key, ['x', 'y']) 8 | const rotation = key => { 9 | const { rx, ry, r } = key 10 | return { x: rx, y: ry, a: r } 11 | } 12 | const size = key => { 13 | const { w = 1, u = w, h = 1 } = key 14 | return { u, h } 15 | } 16 | 17 | function KeyboardLayout(props) { 18 | const { layout, bindings, onUpdate } = props 19 | const normalized = layout.map((_, i) => ( 20 | bindings[i] || { 21 | value: '&none', 22 | params: [] 23 | } 24 | )) 25 | 26 | const handleUpdateBind = useMemo(() => function(keyIndex, updateBinding) { 27 | onUpdate([ 28 | ...normalized.slice(0, keyIndex), 29 | updateBinding, 30 | ...normalized.slice(keyIndex + 1) 31 | ]) 32 | }, [normalized, onUpdate]) 33 | 34 | return ( 35 |
36 | {layout.map((key, i) => ( 37 | handleUpdateBind(i, bind)} 46 | /> 47 | ))} 48 |
49 | ) 50 | } 51 | 52 | KeyboardLayout.propTypes = { 53 | layout: PropTypes.array.isRequired, 54 | bindings: PropTypes.array.isRequired, 55 | onUpdate: PropTypes.func.isRequired 56 | } 57 | 58 | export default KeyboardLayout 59 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/Key.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep' 2 | import get from 'lodash/get' 3 | import pick from 'lodash/pick' 4 | import PropTypes from 'prop-types' 5 | import { useContext, useState } from 'react' 6 | 7 | import { SearchContext } from '../../providers' 8 | import { getBehaviourParams } from '../../keymap' 9 | import { getKeyStyles } from '../../key-units' 10 | 11 | import KeyParamlist from './KeyParamlist' 12 | import * as keyPropTypes from './keyPropTypes' 13 | import { 14 | createPromptMessage, 15 | hydrateTree, 16 | isSimple, 17 | isComplex, 18 | makeIndex 19 | } from './util' 20 | import styles from './styles.module.css' 21 | 22 | import Modal from '../../Common/Modal' 23 | import ValuePicker from '../../ValuePicker' 24 | 25 | function Key(props) { 26 | const { getSearchTargets, sources } = useContext(SearchContext) 27 | const { position, rotation, size } = props 28 | const { label, value, params, onUpdate } = props 29 | const [editing, setEditing] = useState(null) 30 | 31 | const bind = value 32 | const behaviour = get(sources.behaviours, bind) 33 | const behaviourParams = getBehaviourParams(params, behaviour) 34 | 35 | const normalized = hydrateTree(value, params, sources) 36 | 37 | const index = makeIndex(normalized) 38 | const positioningStyle = getKeyStyles(position, size, rotation) 39 | 40 | function onMouseOver(event) { 41 | const old = document.querySelector(`.${styles.highlight}`) 42 | old && old.classList.remove(styles.highlight) 43 | event.target.classList.add(styles.highlight) 44 | } 45 | function onMouseLeave(event) { 46 | event.target.classList.remove(styles.highlight) 47 | } 48 | 49 | function handleSelectCode(event) { 50 | const editing = pick(event, ['target', 'codeIndex', 'code', 'param']) 51 | editing.targets = getSearchTargets(editing.param, value) 52 | setEditing(editing) 53 | } 54 | function handleSelectBehaviour(event) { 55 | event.stopPropagation() 56 | setEditing({ 57 | target: event.target, 58 | targets: getSearchTargets('behaviour', value), 59 | codeIndex: 0, 60 | code: value, 61 | param: 'behaviour' 62 | }) 63 | } 64 | function handleSelectValue(source) { 65 | const { codeIndex } = editing 66 | const updated = cloneDeep(normalized) 67 | const index = makeIndex(updated) 68 | const targetCode = index[codeIndex] 69 | 70 | targetCode.value = source.code 71 | targetCode.params = [] 72 | index.forEach(node => { 73 | delete node.source 74 | }) 75 | 76 | setEditing(null) 77 | onUpdate(pick(updated, ['value', 'params'])) 78 | } 79 | 80 | return ( 81 |
92 | {behaviour ? ( 93 | 97 | {behaviour.code} 98 | 99 | ) : null} 100 | 107 | {editing && ( 108 | 109 | setEditing(null)} 118 | /> 119 | 120 | )} 121 |
122 | ) 123 | } 124 | 125 | Key.propTypes = { 126 | position: PropTypes.shape({ 127 | x: PropTypes.number.isRequired, 128 | y: PropTypes.number.isRequired 129 | }), 130 | rotation: PropTypes.shape({ 131 | a: PropTypes.number, 132 | rx: PropTypes.number, 133 | ry: PropTypes.number 134 | }), 135 | size: PropTypes.shape({ 136 | u: PropTypes.number.isRequired, 137 | h: PropTypes.number.isRequired 138 | }), 139 | label: PropTypes.string, 140 | value: keyPropTypes.value.isRequired, 141 | params: PropTypes.arrayOf(keyPropTypes.node), 142 | onUpdate: PropTypes.func.isRequired 143 | } 144 | 145 | export default Key 146 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/KeyParamlist.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | import PropTypes from 'prop-types' 3 | 4 | import * as keyPropTypes from './keyPropTypes' 5 | import KeyValue from './KeyValue' 6 | import styles from './styles.module.css' 7 | 8 | function KeyParamlist(props) { 9 | const { index, params, values, onSelect, root } = props 10 | return ( 11 | 16 | {params.map((param, i) => ( 17 | 18 | 25 | {get(values[i], 'source.params.length') > 0 ? ( 26 | 32 | ) : null} 33 | 34 | ))} 35 | 36 | ) 37 | } 38 | 39 | KeyParamlist.propTypes = { 40 | index: keyPropTypes.index.isRequired, 41 | params: PropTypes.arrayOf(keyPropTypes.param).isRequired, 42 | values: PropTypes.arrayOf(keyPropTypes.node).isRequired, 43 | source: keyPropTypes.source, 44 | onSelect: PropTypes.func.isRequired 45 | } 46 | 47 | export default KeyParamlist 48 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/KeyValue.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import * as keyPropTypes from './keyPropTypes' 5 | import styles from './styles.module.css' 6 | import Icon from '../../Common/Icon' 7 | 8 | function NullKey() { 9 | return 10 | } 11 | 12 | function KeyValue(props) { 13 | const { param, index, value, source, onSelect } = props 14 | const title = source && `(${source.code}) ${source.description}` 15 | const text = source && (source?.symbol || source?.code) 16 | const icon = source?.faIcon && 17 | 18 | const handleClick = useMemo(() => function (event) { 19 | event.stopPropagation() 20 | onSelect({ 21 | target: event.target, 22 | codeIndex: index, 23 | code: value, 24 | param 25 | }) 26 | }, [param, value, index, onSelect]) 27 | 28 | return ( 29 | 34 | {icon || text || } 35 | 36 | ) 37 | } 38 | 39 | KeyValue.propTypes = { 40 | index: PropTypes.number.isRequired, 41 | param: keyPropTypes.param.isRequired, 42 | value: keyPropTypes.value.isRequired, 43 | source: keyPropTypes.source, 44 | onSelect: PropTypes.func.isRequired 45 | } 46 | 47 | export default KeyValue 48 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/keyPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | 3 | export const param = PropTypes.oneOfType([ 4 | PropTypes.oneOf( 5 | ['code', 'layer', 'mod', 'command'] 6 | ), 7 | PropTypes.shape({ 8 | enum: PropTypes.array.isRequired, 9 | name: PropTypes.string.isRequired, 10 | type: PropTypes.string.isRequired 11 | }) 12 | ]) 13 | export const params = PropTypes.arrayOf(param) 14 | export const value = PropTypes.oneOfType([ 15 | PropTypes.string, 16 | PropTypes.number 17 | ]) 18 | export const source = PropTypes.shape({ 19 | params, 20 | code: value.isRequired, 21 | description: PropTypes.string, 22 | symbol: PropTypes.string, 23 | faIcon: PropTypes.string 24 | }) 25 | 26 | export const node = PropTypes.shape({ 27 | value, 28 | source, 29 | params: PropTypes.arrayOf( 30 | PropTypes.shape({ 31 | value, 32 | source, 33 | params: PropTypes.arrayOf(PropTypes.object) 34 | }) 35 | ) 36 | }) 37 | 38 | export const index = PropTypes.arrayOf(node) 39 | -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/styles.module.css: -------------------------------------------------------------------------------- 1 | .key { 2 | position: absolute; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | 7 | color: #999; 8 | background-color: whitesmoke; 9 | font-size: 110%; 10 | border-radius: 5px; 11 | } 12 | .key:hover { 13 | background-color: var(--hover-selection); 14 | transition: 200ms; 15 | z-index: 1; 16 | } 17 | .key:hover .code, .key:hover .behaviour-binding { 18 | color: white; 19 | } 20 | .key > .code { 21 | padding: 5px; 22 | } 23 | 24 | .key[data-simple="true"] { font-size: 140%; } 25 | .key[data-long="true"] { font-size: 60%; } 26 | 27 | .params:not([data-is-root="true"])::before { content: '('; opacity: 0.4; font-weight: bold; margin: 2px; } 28 | .params:not([data-is-root="true"])::after { content: ')'; opacity: 0.4; font-weight: bold; margin: 2px; } 29 | .params:not([data-is-root="true"]) .param:not(:last-child)::after { content: ','; } 30 | 31 | .code { padding: 0px 4px; margin-left: -2px; margin-right: -2px; } 32 | 33 | .code { 34 | cursor: pointer; 35 | display: inline-block; 36 | box-sizing: content-box; 37 | min-width: 0.5em; 38 | text-align: center; 39 | border-radius: 4px; 40 | } 41 | .code.highlight { 42 | background-color: white !important; 43 | color: var(--hover-selection) !important; 44 | } 45 | 46 | .code * { 47 | pointer-events: none; 48 | } 49 | 50 | .behaviour-binding { 51 | position: absolute; 52 | top: 0; 53 | left: 0; 54 | font-size: 10px; 55 | font-variant: smallcaps; 56 | padding: 2px; 57 | opacity: 0.5; 58 | } 59 | 60 | .behaviour-binding:hover { 61 | cursor: pointer; 62 | color: var(--hover-selection) !important; 63 | background-color: white; 64 | border-radius: 5px 0; 65 | opacity: 1; 66 | } -------------------------------------------------------------------------------- /app/src/Keyboard/Keys/util.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | import keyBy from 'lodash/keyBy' 3 | 4 | import { getBehaviourParams } from '../../keymap' 5 | 6 | export function makeIndex (tree) { 7 | const index = [] 8 | ;(function traverse(tree) { 9 | const params = tree.params || [] 10 | index.push(tree) 11 | params.forEach(traverse) 12 | })(tree) 13 | 14 | return index 15 | } 16 | 17 | export function isSimple(normalized) { 18 | const [first] = normalized.params 19 | const symbol = get(first, 'source.symbol', get(first, 'source.code', '')) 20 | const shortSymbol = symbol.length === 1 21 | const singleParam = normalized.params.length === 1 22 | return singleParam && shortSymbol 23 | } 24 | 25 | export function isComplex(normalized, behaviourParams) { 26 | const [first] = normalized.params 27 | const symbol = get(first, 'source.symbol', get(first, 'value', '')) 28 | const isLongSymbol = symbol.length > 4 29 | const isMultiParam = behaviourParams.length > 1 30 | const isNestedParam = get(first, 'params', []).length > 0 31 | 32 | return isLongSymbol || isMultiParam || isNestedParam 33 | } 34 | 35 | export function createPromptMessage(param) { 36 | const promptMapping = { 37 | layer: 'Select layer', 38 | mod: 'Select modifier', 39 | behaviour: 'Select behaviour', 40 | command: 'Select command', 41 | keycode: 'Select key code' 42 | } 43 | 44 | if (param.name) { 45 | return `Select ${param.name}` 46 | } 47 | 48 | return ( 49 | promptMapping[param] || 50 | promptMapping.keycode 51 | ) 52 | } 53 | 54 | export function hydrateTree(value, params, sources) { 55 | const bind = value 56 | const behaviour = get(sources.behaviours, bind) 57 | const behaviourParams = getBehaviourParams(params, behaviour) 58 | const commands = keyBy(behaviour.commands, 'code') 59 | 60 | function getSourceValue(value, as) { 61 | if (as === 'command') return commands[value] 62 | if (as === 'raw' || as.enum) return { code: value } 63 | return sources?.[as]?.[value] 64 | } 65 | 66 | function hydrateNode(node, as) { 67 | if (!node) { 68 | return { value: undefined, params: [] } 69 | } 70 | const { value, params } = node 71 | const source = getSourceValue(value, as) 72 | 73 | return { 74 | value, 75 | source, 76 | params: get(source, 'params', []).map((as, i) => ( 77 | hydrateNode(params[i], as) 78 | )) 79 | } 80 | } 81 | 82 | return { 83 | value, 84 | source: behaviour, 85 | params: behaviourParams.map((as, i) => ( 86 | hydrateNode(params[i], as) 87 | )) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/src/Keyboard/LayerSelector.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 3 | 4 | import Icon from '../Common/Icon' 5 | import styles from './styles.module.css' 6 | 7 | function stop(fn) { 8 | return function(event) { 9 | event.stopPropagation() 10 | fn() 11 | } 12 | } 13 | 14 | function onKey(mapping) { 15 | return function(event) { 16 | if (mapping[event.key]) { 17 | mapping[event.key]() 18 | } 19 | } 20 | } 21 | 22 | function LayerSelector(props) { 23 | const ref = useRef(null) 24 | const { activeLayer, layers } = props 25 | const { onSelect, onNewLayer, onRenameLayer, onDeleteLayer } = props 26 | const [renaming, setRenaming] = useState(false) 27 | const [editing, setEditing] = useState('') 28 | 29 | const handleSelect = useMemo(() => function(layer) { 30 | if (layer === activeLayer) { 31 | setEditing(layers[activeLayer]) 32 | setRenaming(true) 33 | return 34 | } 35 | 36 | setRenaming(false) 37 | onSelect(layer) 38 | }, [layers, activeLayer, setEditing, setRenaming, onSelect]) 39 | 40 | const handleAdd = useMemo(() => function() { 41 | onNewLayer() 42 | }, [onNewLayer]) 43 | 44 | const handleDelete = useMemo(() => function(layerIndex, layerName) { 45 | const confirmation = `Really delete layer: ${layerName}?` 46 | window.confirm(confirmation) && onDeleteLayer(layerIndex) 47 | }, [onDeleteLayer]) 48 | 49 | const finishEditing = useCallback(() => { 50 | if (!renaming) { 51 | return 52 | } 53 | 54 | setEditing('') 55 | setRenaming(false) 56 | onRenameLayer(editing) 57 | }, [editing, renaming, setEditing, setRenaming, onRenameLayer]) 58 | 59 | const cancelEditing = useCallback(() => { 60 | if (!renaming) { 61 | return 62 | } 63 | 64 | setEditing('') 65 | setRenaming(false) 66 | }, [renaming, setEditing, setRenaming]) 67 | 68 | const handleClickOutside = useMemo(() => function(event) { 69 | const clickedOutside = ref.current && !ref.current.contains(event.target) 70 | if (!clickedOutside) { 71 | return 72 | } 73 | 74 | cancelEditing() 75 | }, [ref, cancelEditing]) 76 | 77 | useEffect(() => { 78 | document.addEventListener('click', handleClickOutside) 79 | return () => document.removeEventListener('click', handleClickOutside) 80 | }, [handleClickOutside]) 81 | 82 | const focusInput = useCallback(node => { 83 | if (node) { 84 | node.focus() 85 | node.select() 86 | } 87 | }, []) 88 | 89 | return ( 90 |
95 |

Layers:

96 |
    97 | {layers.map((name, i) => ( 98 |
  • handleSelect(i))} 103 | > 104 | {i} 105 | {(activeLayer === i && renaming) ? ( 106 | setEditing(e.target.value)} 110 | onKeyDown={onKey({ 111 | Enter: finishEditing, 112 | Escape: cancelEditing 113 | })} 114 | value={ 115 | (activeLayer === i && renaming) 116 | ? editing 117 | : layers[i] 118 | } 119 | /> 120 | ) : ( 121 | 122 | {name} 123 | handleDelete(i, name))} 127 | /> 128 | 129 | )} 130 |
  • 131 | ))} 132 |
  • 133 | 134 | Add Layer 135 |
  • 136 |
137 |
138 | ) 139 | } 140 | 141 | LayerSelector.propTypes = { 142 | layers: PropTypes.array.isRequired, 143 | activeLayer: PropTypes.number.isRequired, 144 | onSelect: PropTypes.func.isRequired, 145 | onNewLayer: PropTypes.func.isRequired, 146 | onRenameLayer: PropTypes.func.isRequired, 147 | onDeleteLayer: PropTypes.func.isRequired 148 | } 149 | 150 | export default LayerSelector 151 | -------------------------------------------------------------------------------- /app/src/Keyboard/styles.module.css: -------------------------------------------------------------------------------- 1 | .layer-selector { 2 | position: absolute; 3 | z-index: 2; 4 | } 5 | 6 | .layer-selector ul { 7 | display: inline-block; 8 | list-style-type: none; 9 | margin: 0; 10 | padding: 0; 11 | } 12 | .layer-selector li { 13 | cursor: pointer; 14 | background-color: rgba(201, 201, 201, 0.85); 15 | color: darkgray; 16 | border-radius: 15px; 17 | height: 30px; 18 | padding: 0px; 19 | margin: 4px 2px; 20 | 21 | } 22 | .layer-selector li:hover { 23 | background-color: rgba(60, 179, 113, 0.85); 24 | color: white; 25 | } 26 | .layer-selector li.active { 27 | background-color: rgb(60, 179, 113); 28 | color: white; 29 | } 30 | 31 | .layer-selector li * { 32 | display: inline-block; 33 | } 34 | .layer-selector li .index { 35 | overflow: auto; 36 | width: 30px; 37 | height: 30px; 38 | line-height: 30px; 39 | text-align: center; 40 | } 41 | .layer-selector li .name { 42 | overflow: hidden; 43 | width: 0; 44 | height: 30px; 45 | line-height: 30px; 46 | padding: 0; 47 | font-variant: small-caps; 48 | } 49 | 50 | .layer-selector:hover li .name, 51 | .layer-selector[data-renaming="true"] li .name { 52 | transition: .15s ease-in; 53 | width: 120px; 54 | padding: 0 0 0 10px; 55 | } 56 | 57 | .layer-selector button { 58 | width: 30px; 59 | height: 30px; 60 | line-height: 30px; 61 | padding: 0; 62 | text-align: center; 63 | border-radius: 15px; 64 | } 65 | 66 | .layer-selector input.name { 67 | vertical-align: top; 68 | width: 100px; 69 | border: none; 70 | outline: none; 71 | background: transparent; 72 | color: white; 73 | } 74 | 75 | .layer-selector .delete { 76 | float: right; 77 | height: 30px; 78 | line-height: 30px; 79 | width: 30px; 80 | } 81 | 82 | .layer-selector li.active .name { 83 | cursor: text; 84 | } -------------------------------------------------------------------------------- /app/src/Pickers/Github/InvalidRepo.js: -------------------------------------------------------------------------------- 1 | import Modal from "../../Common/Modal" 2 | import DialogBox from "../../Common/DialogBox" 3 | 4 | export default function InvalidRepo(props) { 5 | const { onDismiss, otherRepoOrBranchAvailable = false } = props 6 | const demoRepoUrl = 'https://github.com/nickcoutsos/zmk-config-corne-demo/' 7 | 8 | return ( 9 | 10 | 11 |

Hold up a second!

12 |

13 | The selected repository does not contain info.json or 14 | keymap.json. 15 |

16 |

17 | This app depends on some additional metadata to render the keymap. 18 | For an example repository ready to use now or metadata you can apply 19 | to your own keyboard repo, have a look at 20 | zmk-config-corne-demo. 21 |

22 | {otherRepoOrBranchAvailable && ( 23 |

24 | If you have another branch or repository the the required metadata 25 | files you may switch to them instead. 26 |

27 | )} 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/Pickers/Github/Picker.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/find' 2 | import map from 'lodash/map' 3 | import { useEffect, useMemo, useState } from 'react' 4 | import PropTypes from 'prop-types' 5 | 6 | import github from './api' 7 | import * as storage from './storage' 8 | import ValidationErrors from './ValidationErrors' 9 | 10 | import IconButton from '../../Common/IconButton' 11 | import Selector from '../../Common/Selector' 12 | import Spinner from '../../Common/Spinner' 13 | 14 | function Login() { 15 | return ( 16 | github.beginLoginFlow()} 21 | /> 22 | ) 23 | } 24 | 25 | function Install() { 26 | return ( 27 | github.beginInstallAppFlow()} 32 | /> 33 | ) 34 | } 35 | 36 | function GithubPicker(props) { 37 | const [state, setState] = useState({ 38 | initialized: false, 39 | selectedRepoId: null, 40 | selectedBranchName: null, 41 | branches: [], 42 | loadingBranches: false, 43 | loadingKeyboard: false, 44 | loadError: null, 45 | loadWarnings: null 46 | }) 47 | 48 | const { initialized, branches, selectedRepoId, selectedBranchName } = state 49 | const { loadingBranches, loadingKeyboard, loadError, loadWarnings } = state 50 | 51 | const { onSelect } = props 52 | 53 | const clearSelection = useMemo(() => function () { 54 | setState(state => ({ 55 | ...state, 56 | selectedBranchName: null, 57 | loadError: null, 58 | loadWarnings: null 59 | })) 60 | }, [setState]) 61 | 62 | const lintKeyboard = useMemo(() => function ({ layout }) { 63 | const noKeyHasPosition = layout.every(key => ( 64 | key.row === undefined && 65 | key.col === undefined 66 | )) 67 | 68 | if (noKeyHasPosition) { 69 | setState(state => ({ ...state, loadWarnings: [ 70 | 'Layout in info.json has no row/col definitions. Generated keymap files will not be nicely formatted.' 71 | ]})) 72 | } 73 | }, [setState]) 74 | 75 | const loadKeyboard = useMemo(() => async function () { 76 | const available = github.repositories 77 | const repository = find(available, { id: selectedRepoId })?.full_name 78 | const branch = selectedBranchName 79 | 80 | setState(state => ({ ...state, loadingKeyboard: true, loadError: null })) 81 | 82 | const response = await github.fetchLayoutAndKeymap(repository, branch) 83 | 84 | setState(state => ({ ...state, loadingKeyboard: false })) 85 | lintKeyboard(response) 86 | 87 | onSelect({ 88 | github: { repository, branch }, 89 | ...response 90 | }) 91 | }, [ 92 | selectedRepoId, 93 | selectedBranchName, 94 | setState, 95 | lintKeyboard, 96 | onSelect 97 | ]) 98 | 99 | useEffect(() => { 100 | github.init().then(() => { 101 | const persistedRepoId = storage.getPersistedRepository() 102 | const repositories = github.repositories || [] 103 | let selectedRepoId 104 | 105 | if (find(repositories, { id: persistedRepoId })) { 106 | selectedRepoId = persistedRepoId 107 | } else if (repositories.length > 0) { 108 | selectedRepoId = repositories[0].id 109 | } 110 | 111 | setState(state => ({ 112 | ...state, 113 | initialized: true, 114 | selectedRepoId 115 | })) 116 | }) 117 | }, []) 118 | 119 | useEffect(() => { 120 | github.on('authentication-failed', () => { 121 | github.beginLoginFlow() 122 | }) 123 | }, []) 124 | 125 | useEffect(() => { 126 | github.on('repo-validation-error', err => { 127 | setState(state => ({ 128 | ...state, 129 | loadError: err, 130 | loadingKeyboard: false 131 | })) 132 | }) 133 | }, []) 134 | 135 | useEffect(() => { 136 | if (!selectedRepoId) { 137 | return 138 | } 139 | 140 | storage.setPersistedRepository(selectedRepoId) 141 | 142 | ;(async function() { 143 | setState(state => ({ ...state, loadingBranches: true })) 144 | 145 | const repository = find(github.repositories, { id: selectedRepoId }) 146 | const branches = await github.fetchRepoBranches(repository) 147 | 148 | setState(state => ({ ...state, branches, loadingBranches: false })) 149 | 150 | const available = map(branches, 'name') 151 | const defaultBranch = repository.default_branch 152 | const previousBranch = storage.getPersistedBranch(selectedRepoId) 153 | const onlyBranch = branches.length === 1 ? branches[0].name : null 154 | 155 | for (let branch of [onlyBranch, previousBranch, defaultBranch]) { 156 | if (available.includes(branch)) { 157 | setState(state => ({ ...state, selectedBranchName: branch })) 158 | break 159 | } 160 | } 161 | })() 162 | }, [selectedRepoId]) 163 | 164 | useEffect(() => { 165 | if (!selectedRepoId || !selectedBranchName) { 166 | return 167 | } 168 | 169 | storage.setPersistedBranch(selectedRepoId, selectedBranchName) 170 | loadKeyboard() 171 | }, [selectedRepoId, selectedBranchName, loadKeyboard]) 172 | 173 | if (!initialized) { 174 | return null 175 | } 176 | 177 | if (!github.isGitHubAuthorized()) return 178 | if (!github.isAppInstalled()) return 179 | 180 | const repositoryChoices = github.repositories.map(repo => ({ 181 | id: repo.id, 182 | name: repo.full_name 183 | })) 184 | 185 | const branchChoices = branches.map(branch => ({ 186 | id: branch.name, 187 | name: branch.name 188 | })) 189 | 190 | return ( 191 | <> 192 | setState(state => ({ 198 | ...state, 199 | selectedRepoId: id 200 | }))} 201 | /> 202 | 203 | {loadingBranches ? ( 204 | 205 | ) : branches.length && ( 206 | setState(state => ({ 212 | ...state, 213 | selectedBranchName: name 214 | }))} 215 | /> 216 | )} 217 | 218 | {loadingKeyboard && } 219 | 220 | {loadError && ( 221 | 1 226 | || branchChoices.length > 0 227 | } 228 | onDismiss={clearSelection} 229 | /> 230 | )} 231 | {loadWarnings && ( 232 | setState(state => ({ ...state, loadWarnings: null }))} 236 | /> 237 | )} 238 | 239 | {selectedBranchName && !loadingKeyboard && ( 240 | 241 | )} 242 | 243 | ) 244 | } 245 | 246 | GithubPicker.propTypes = { 247 | onSelect: PropTypes.func.isRequired 248 | } 249 | 250 | export default GithubPicker 251 | -------------------------------------------------------------------------------- /app/src/Pickers/Github/ValidationErrors.js: -------------------------------------------------------------------------------- 1 | import DialogBox from "../../Common/DialogBox" 2 | import Modal from "../../Common/Modal" 3 | 4 | function fileFromTitle(title) { 5 | if (title === 'InfoValidationError') { 6 | return 'config/info.json' 7 | } else if (title === 'KeymapValidationError') { 8 | return 'config/keymap.json' 9 | } 10 | } 11 | 12 | const listStyle = { 13 | maxHeight: '300px', 14 | overflow: 'auto', 15 | padding: '10px', 16 | fontFamily: 'monospace', 17 | fontSize: '80%', 18 | backgroundColor: '#efefef' 19 | } 20 | 21 | const listItemStyle = { margin: '10px' } 22 | 23 | export default function ValidationErrors(props) { 24 | const { onDismiss, title, errors, otherRepoOrBranchAvailable = false } = props 25 | const file = fileFromTitle(title) 26 | 27 | return ( 28 | 29 | 30 |

{title}

31 | {file && ( 32 |

Errors in the file {file}.

33 | )} 34 |
    35 | {errors.map((error, i) => ( 36 |
  • 37 | {error} 38 |
  • 39 | ))} 40 |
41 | 42 | {otherRepoOrBranchAvailable && ( 43 |

44 | If you have another branch or repository the the required metadata files 45 | you may switch to them instead. 46 |

47 | )} 48 |
49 |
50 | ) 51 | } -------------------------------------------------------------------------------- /app/src/Pickers/Github/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import EventEmitter from 'eventemitter3' 3 | 4 | import * as config from '../../config' 5 | 6 | export class API extends EventEmitter { 7 | token = null 8 | initialized = false 9 | installations = null 10 | repositories = null 11 | repoInstallationMap = null 12 | 13 | async _request (options) { 14 | if (typeof options === 'string') { 15 | options = { 16 | url: options 17 | } 18 | } 19 | 20 | if (options.url.startsWith('/')) { 21 | options.url = `${config.apiBaseUrl}${options.url}` 22 | } 23 | 24 | options.headers = Object.assign({}, options.headers) 25 | if (this.token && !options.headers.Authorization) { 26 | options.headers.Authorization = `Bearer ${this.token}` 27 | } 28 | 29 | try { 30 | return await axios(options) 31 | } catch (err) { 32 | if (err.response?.status === 401) { 33 | console.error('Authentication failed.') 34 | this.emit('authentication-failed', err.response) 35 | } 36 | 37 | throw err 38 | } 39 | } 40 | 41 | async init() { 42 | if (this.initialized) { 43 | return 44 | } 45 | 46 | const installationUrl = `${config.apiBaseUrl}/github/installation` 47 | const param = new URLSearchParams(window.location.search).get('token') 48 | if (!localStorage.auth_token && param) { 49 | window.history.replaceState({}, null, window.location.pathname) 50 | localStorage.auth_token = param 51 | } 52 | 53 | if (localStorage.auth_token) { 54 | this.token = localStorage.auth_token 55 | const { data } = await this._request(installationUrl) 56 | this.emit('authenticated') 57 | 58 | if (!data.installation) { 59 | console.warn('No GitHub app installation found for authenticated user.') 60 | this.emit('app-not-installed') 61 | } 62 | 63 | this.installations = data.installations 64 | this.repositories = data.repositories 65 | this.repoInstallationMap = data.repoInstallationMap 66 | } 67 | } 68 | 69 | beginLoginFlow() { 70 | localStorage.removeItem('auth_token') 71 | window.location.href = `${config.apiBaseUrl}/github/authorize` 72 | } 73 | 74 | beginInstallAppFlow() { 75 | window.location.href = `https://github.com/apps/${config.githubAppName}/installations/new` 76 | } 77 | 78 | isGitHubAuthorized() { 79 | return !!this.token 80 | } 81 | 82 | isAppInstalled() { 83 | return this.installations?.length && this.repositories?.length 84 | } 85 | 86 | async fetchRepoBranches(repo) { 87 | const installation = encodeURIComponent(this.repoInstallationMap[repo.full_name]) 88 | const repository = encodeURIComponent(repo.full_name) 89 | const { data } = await this._request( 90 | `/github/installation/${installation}/${repository}/branches` 91 | ) 92 | 93 | return data 94 | } 95 | 96 | async fetchLayoutAndKeymap(repo, branch) { 97 | const installation = encodeURIComponent(this.repoInstallationMap[repo]) 98 | const repository = encodeURIComponent(repo) 99 | const url = new URL(`${config.apiBaseUrl}/github/keyboard-files/${installation}/${repository}`) 100 | 101 | if (branch) { 102 | url.search = new URLSearchParams({ branch }).toString() 103 | } 104 | 105 | try { 106 | const { data } = await this._request(url.toString()) 107 | const defaultLayout = data.info.layouts.default || data.info.layouts[Object.keys(data.info.layouts)[0]] 108 | return { 109 | layout: defaultLayout.layout, 110 | keymap: data.keymap 111 | } 112 | } catch (err) { 113 | if (err.response?.status === 400) { 114 | console.error('Failed to load keymap and layout from github', err.response.data) 115 | this.emit('repo-validation-error', err.response.data) 116 | } 117 | 118 | throw err 119 | } 120 | } 121 | 122 | commitChanges(repo, branch, layout, keymap) { 123 | const installation = encodeURIComponent(this.repoInstallationMap[repo]) 124 | const repository = encodeURIComponent(repo) 125 | 126 | return this._request({ 127 | url: `/github/keyboard-files/${installation}/${repository}/${encodeURIComponent(branch)}`, 128 | method: 'POST', 129 | headers: { 'Content-Type': 'application/json' }, 130 | data: { layout, keymap } 131 | }) 132 | } 133 | } 134 | 135 | export default new API() 136 | -------------------------------------------------------------------------------- /app/src/Pickers/Github/storage.js: -------------------------------------------------------------------------------- 1 | const REPOSITORY = 'selectedGithubRepository' 2 | const BRANCH = 'selectedGithubBranch' 3 | 4 | export function getPersistedRepository() { 5 | try { 6 | return JSON.parse(localStorage.getItem(REPOSITORY)) 7 | } catch { 8 | return null 9 | } 10 | } 11 | 12 | export function setPersistedRepository(repository) { 13 | localStorage.setItem(REPOSITORY, JSON.stringify(repository)) 14 | } 15 | 16 | export function getPersistedBranch(repoId) { 17 | try { 18 | return JSON.parse(localStorage.getItem(`${BRANCH}:${repoId}`)) 19 | } catch { 20 | return null 21 | } 22 | } 23 | 24 | export function setPersistedBranch(repoId, branch) { 25 | localStorage.setItem(`${BRANCH}:${repoId}`, JSON.stringify(branch)) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/Pickers/KeyboardPicker.js: -------------------------------------------------------------------------------- 1 | import compact from 'lodash/compact' 2 | import { useEffect, useMemo, useState } from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | import * as config from '../config' 6 | import { loadLayout } from '../layout.js' 7 | import { loadKeymap } from '../keymap.js' 8 | import Selector from "../Common/Selector" 9 | import GithubPicker from './Github/Picker' 10 | 11 | const sourceChoices = compact([ 12 | config.enableLocal ? { id: 'local', name: 'Local' } : null, 13 | config.enableGitHub ? { id: 'github', name: 'GitHub' } : null 14 | ]) 15 | 16 | const selectedSource = localStorage.getItem('selectedSource') 17 | const onlySource = sourceChoices.length === 1 ? sourceChoices[0].id : null 18 | const defaultSource = onlySource || ( 19 | sourceChoices.find(source => source.id === selectedSource) 20 | ? selectedSource 21 | : null 22 | ) 23 | 24 | function KeyboardPicker(props) { 25 | const { onSelect } = props 26 | const [source, setSource] = useState(defaultSource) 27 | 28 | const handleKeyboardSelected = useMemo(() => function (event) { 29 | const { layout, keymap, ...rest } = event 30 | 31 | const layerNames = keymap.layer_names || keymap.layers.map((_, i) => `Layer ${i}`) 32 | Object.assign(keymap, { 33 | layer_names: layerNames 34 | }) 35 | 36 | onSelect({ source, layout, keymap, ...rest }) 37 | }, [onSelect, source]) 38 | 39 | const fetchLocalKeyboard = useMemo(() => async function() { 40 | const [layout, keymap] = await Promise.all([ 41 | loadLayout(), 42 | loadKeymap() 43 | ]) 44 | 45 | handleKeyboardSelected({ source, layout, keymap }) 46 | }, [source, handleKeyboardSelected]) 47 | 48 | useEffect(() => { 49 | localStorage.setItem('selectedSource', source) 50 | if (source === 'local') { 51 | fetchLocalKeyboard() 52 | } 53 | }, [source, fetchLocalKeyboard]) 54 | 55 | return ( 56 |
57 | { 63 | setSource(value) 64 | onSelect(value) 65 | }} 66 | /> 67 | 68 | {source === 'github' && ( 69 | 70 | )} 71 |
72 | ) 73 | } 74 | 75 | KeyboardPicker.propTypes = { 76 | onSelect: PropTypes.func.isRequired 77 | } 78 | 79 | export default KeyboardPicker 80 | -------------------------------------------------------------------------------- /app/src/ValuePicker/index.js: -------------------------------------------------------------------------------- 1 | import fuzzysort from 'fuzzysort' 2 | import PropTypes from 'prop-types' 3 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 4 | 5 | import style from './style.module.css' 6 | 7 | const cycle = (array, index, step=1) => { 8 | const next = (index + step) % array.length 9 | return next < 0 ? array.length + next : next 10 | } 11 | 12 | function scrollIntoViewIfNeeded (element, alignToTop) { 13 | const scroll = element.offsetParent.scrollTop 14 | const height = element.offsetParent.offsetHeight 15 | const top = element.offsetTop 16 | const bottom = top + element.scrollHeight 17 | 18 | if (top < scroll || bottom > scroll + height) { 19 | element.scrollIntoView(alignToTop) 20 | } 21 | } 22 | 23 | function ValuePicker (props) { 24 | const { value, prompt, choices, searchKey, searchThreshold, showAllThreshold } = props 25 | const { onCancel, onSelect } = props 26 | 27 | const listRef = useRef(null) 28 | 29 | const [query, setQuery] = useState(null) 30 | const [highlighted, setHighlighted] = useState(null) 31 | const [showAll, setShowAll] = useState(false) 32 | 33 | const results = useMemo(() => { 34 | const options = { key: searchKey, limit: 30 } 35 | const filtered = fuzzysort.go(query, choices, options) 36 | 37 | if (showAll || searchThreshold > choices.length) { 38 | return choices 39 | } else if (!query) { 40 | return choices.slice(0, searchThreshold) 41 | } 42 | 43 | return filtered.map(result => ({ 44 | ...result.obj, 45 | search: result 46 | })) 47 | }, [query, choices, searchKey, showAll, searchThreshold]) 48 | 49 | const enableShowAllButton = useMemo(() => { 50 | return ( 51 | !showAll && 52 | choices.length > searchThreshold && 53 | choices.length <= showAllThreshold 54 | ) 55 | }, [showAll, choices, searchThreshold, showAllThreshold]) 56 | 57 | const handleClickResult = useMemo(() => function(result) { 58 | onSelect(result) 59 | }, [onSelect]) 60 | 61 | const handleClickOutside = useMemo(() => function(event) { 62 | if (!listRef.current.contains(event.target)) { 63 | onCancel() 64 | } 65 | }, [listRef, onCancel]) 66 | 67 | const handleSelectActive = useMemo(() => function() { 68 | if (results.length > 0 && highlighted !== null) { 69 | handleClickResult(results[highlighted]) 70 | } 71 | }, [results, highlighted, handleClickResult]) 72 | 73 | const setHighlightPosition = useMemo(() => function(initial, offset) { 74 | if (results.length === 0) { 75 | setHighlighted(null) 76 | return 77 | } 78 | if (offset === undefined) { 79 | setHighlighted(initial) 80 | return 81 | } 82 | 83 | const next = highlighted !== null 84 | ? cycle(results, highlighted, offset) 85 | : initial 86 | 87 | const selector = `li[data-result-index="${next}"]` 88 | const element = listRef.current?.querySelector(selector) 89 | 90 | scrollIntoViewIfNeeded(element, false) 91 | setHighlighted(next) 92 | }, [results, highlighted, setHighlighted]) 93 | 94 | const handleHighlightNext = useMemo(() => function() { 95 | setHighlightPosition(0, 1) 96 | }, [setHighlightPosition]) 97 | 98 | const handleHightightPrev = useMemo(() => function() { 99 | setHighlightPosition(results.length - 1, -1) 100 | }, [setHighlightPosition, results]) 101 | 102 | const handleKeyPress = useMemo(() => function(event) { 103 | setQuery(event.target.value) 104 | }, [setQuery]) 105 | 106 | const handleKeyDown = useMemo(() => function (event) { 107 | const mapping = { 108 | ArrowDown: handleHighlightNext, 109 | ArrowUp: handleHightightPrev, 110 | Enter: handleSelectActive, 111 | Escape: onCancel 112 | } 113 | 114 | const action = mapping[event.key] 115 | if (action) { 116 | event.stopPropagation() 117 | action() 118 | } 119 | }, [ 120 | handleHighlightNext, 121 | handleHightightPrev, 122 | handleSelectActive, 123 | onCancel 124 | ]) 125 | 126 | const focusSearch = useCallback(node => { 127 | if (node) { 128 | node.focus() 129 | node.select() 130 | } 131 | }, []) 132 | 133 | useEffect(() => { 134 | document.body.addEventListener('click', handleClickOutside) 135 | 136 | return () => { 137 | document.body.removeEventListener('click', handleClickOutside) 138 | } 139 | }, [handleClickOutside]) 140 | 141 | return ( 142 |
143 |

{prompt}

144 | {choices.length > searchThreshold && ( 145 | 151 | )} 152 |
    153 | {results.map((result, i) => ( 154 |
  • handleClickResult(result)} 160 | onMouseOver={() => setHighlightPosition(i)} 161 | > 162 | {result.search ? ( 163 | 166 | ) : ( 167 | 168 | {result[searchKey]} 169 | 170 | )} 171 |
  • 172 | ))} 173 |
174 | {choices.length > searchThreshold && ( 175 |
176 | Total choices: {choices.length}. 177 | {enableShowAllButton && ( 178 | 179 | )} 180 |
181 | )} 182 |
183 | ) 184 | } 185 | 186 | ValuePicker.propTypes = { 187 | target: PropTypes.object.isRequired, 188 | choices: PropTypes.array.isRequired, 189 | param: PropTypes.oneOfType([ 190 | PropTypes.string, 191 | PropTypes.object 192 | ]).isRequired, 193 | value: PropTypes.string.isRequired, 194 | prompt: PropTypes.string.isRequired, 195 | searchKey: PropTypes.string.isRequired, 196 | searchThreshold: PropTypes.number, 197 | showAllThreshold: PropTypes.number, 198 | onCancel: PropTypes.func.isRequired, 199 | onSelect: PropTypes.func.isRequired 200 | } 201 | 202 | ValuePicker.defaultProps = { 203 | searchThreshold: 10, 204 | showAllThreshold: 50 205 | } 206 | 207 | export default ValuePicker 208 | -------------------------------------------------------------------------------- /app/src/ValuePicker/style.module.css: -------------------------------------------------------------------------------- 1 | .dialog { 2 | width: 300px; 3 | } 4 | .dialog p { 5 | margin: 0; 6 | font-size: 90%; 7 | font-weight: bold; 8 | } 9 | .dialog input { 10 | display: block; 11 | width: 100%; 12 | height: 30px; 13 | line-height: 30px; 14 | 15 | font-size: 120%; 16 | margin: 0; 17 | padding: 4px; 18 | border: none; 19 | border-radius: 4px; 20 | box-sizing: border-box; 21 | } 22 | ul.results { 23 | font-family: monospace; 24 | list-style-position: inside; 25 | list-style-type: none; 26 | max-height: 200px; 27 | overflow: scroll; 28 | padding: 4px; 29 | margin: 4px 0; 30 | background: rgba(0, 0, 0, 0.8); 31 | border-radius: 4px; 32 | } 33 | .results li { 34 | cursor: pointer; 35 | color: white; 36 | padding: 5px; 37 | } 38 | .results li:hover, .results li.highlighted { 39 | background: white; 40 | color: black; 41 | } 42 | .results li b { color: red; } 43 | 44 | .choices-counter { 45 | font-size: 10px; 46 | } 47 | 48 | .choices-counter a { 49 | color: var(--selection); 50 | border-bottom: 1px dotted var(--selection); 51 | cursor: pointer; 52 | } 53 | -------------------------------------------------------------------------------- /app/src/api.js: -------------------------------------------------------------------------------- 1 | import * as config from './config' 2 | 3 | export function healthcheck() { 4 | return fetch(`${config.apiBaseUrl}/health`) 5 | } 6 | 7 | export function loadBehaviours() { 8 | return fetch(`${config.apiBaseUrl}/behaviors`).then(response => response.json()) 9 | } 10 | 11 | export function loadKeycodes() { 12 | return fetch(`${config.apiBaseUrl}/keycodes`).then(response => response.json()) 13 | } 14 | 15 | export function loadKeymap() { 16 | return fetch(`${config.apiBaseUrl}/keymap`) 17 | .then(response => response.json()) 18 | } 19 | 20 | export function loadLayout() { 21 | return fetch(`${config.apiBaseUrl}/layout`) 22 | .then(response => response.json()) 23 | } 24 | -------------------------------------------------------------------------------- /app/src/config.js: -------------------------------------------------------------------------------- 1 | function parseBoolean (val) { 2 | return val && ['1', 'on', 'yes', 'true'].includes(val.toString().toLowerCase()) 3 | } 4 | 5 | function env(key) { 6 | return process.env[key] || process.env[`REACT_APP_${key}`] 7 | } 8 | 9 | export const apiBaseUrl = env('API_BASE_URL') 10 | export const appBaseUrl = env('APP_BASE_URL') 11 | export const githubAppName = env('GITHUB_APP_NAME') 12 | export const enableGitHub = parseBoolean(env('ENABLE_GITHUB')) 13 | export const enableLocal = parseBoolean(env('ENABLE_LOCAL')) 14 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('app-root')); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /app/src/key-units.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map' 2 | 3 | const DEFAULT_SIZE = 65; 4 | const DEFAULT_PADDING = 5; 5 | 6 | export function getComputedParams (position, size, rotation = {}) { 7 | return { 8 | x: position.x * (DEFAULT_SIZE + DEFAULT_PADDING), 9 | y: position.y * (DEFAULT_SIZE + DEFAULT_PADDING), 10 | u: size.u * DEFAULT_SIZE + DEFAULT_PADDING * (size.u - 1), 11 | h: size.h * DEFAULT_SIZE + DEFAULT_PADDING * (size.h - 1), 12 | rx: (position.x - (rotation.x || position.x)) * -(DEFAULT_SIZE + DEFAULT_PADDING), 13 | ry: (position.y - (rotation.y || position.y)) * -(DEFAULT_SIZE + DEFAULT_PADDING), 14 | a: rotation.a || 0 15 | } 16 | } 17 | 18 | export function getKeyStyles (position, size, rotation) { 19 | const { x, y, u, h, a, rx, ry } = getComputedParams (position, size, rotation) 20 | 21 | return { 22 | top: `${y}px`, 23 | left: `${x}px`, 24 | width: `${u}px`, 25 | height: `${h}px`, 26 | transformOrigin: `${rx}px ${ry}px`, 27 | transform: `rotate(${a || 0}deg)` 28 | } 29 | } 30 | 31 | export function getKeyBoundingBox(position, size, rotation) { 32 | const { x, y, u, h, a, rx, ry } = getComputedParams(position, size, rotation) 33 | 34 | const points = [ 35 | { x: 0, y: 0 }, 36 | { x: u, y: 0 }, 37 | { x: u, y: h }, 38 | { x: 0, y: h } 39 | ] 40 | 41 | function translate(point) { 42 | return { 43 | x: point.x + x, 44 | y: point.y + y 45 | } 46 | } 47 | 48 | function rotate(point) { 49 | const x = point.x - rx 50 | const y = point.y - ry 51 | const angle = Math.PI * a / 180 52 | 53 | return { 54 | x: rx + x * Math.cos(angle) - y * Math.sin(angle), 55 | y: ry + y * Math.cos(angle) + x * Math.sin(angle) 56 | } 57 | } 58 | 59 | const transformed = points.map(rotate).map(translate) 60 | const xValues = map(transformed, 'x') 61 | const yValues = map(transformed, 'y') 62 | const min = { 63 | x: Math.min(...xValues), 64 | y: Math.min(...yValues) 65 | } 66 | const max = { 67 | x: Math.max(...xValues), 68 | y: Math.max(...yValues) 69 | } 70 | 71 | return { min, max } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/keycodes.js: -------------------------------------------------------------------------------- 1 | import * as api from './api' 2 | 3 | export function loadBehaviours () { 4 | return api.loadBehaviours() 5 | } 6 | 7 | export function loadKeycodes () { 8 | return api.loadKeycodes().then(normalizeZmkKeycodes) 9 | } 10 | 11 | function shortestAlias (aliases) { 12 | return [...aliases] 13 | .sort((a, b) => a.length - b.length)[0] 14 | .replace(/^KC_/, '') 15 | } 16 | 17 | function normalizeZmkKeycodes (keycodes) { 18 | const fnPattern = /^(.+?)\((code)\)$/ 19 | 20 | return keycodes.reduce((keycodes, keycode) => { 21 | const { description, context, symbol, faIcon } = keycode 22 | const aliases = keycode.names.filter(name => !name.match(fnPattern)) 23 | const fnCode = keycode.names.map(name => name.match(fnPattern)).filter(v => !!v)[0] 24 | const base = { aliases, description, context, faIcon, symbol: symbol || shortestAlias(aliases), params: [] } 25 | 26 | for (let code of aliases) { 27 | keycodes.push(Object.assign({}, base, { 28 | code, 29 | isModifier: !!fnCode 30 | })) 31 | } 32 | 33 | if (fnCode) { 34 | keycodes.push(Object.assign({}, base, { 35 | code: fnCode[1], 36 | params: fnCode[2].split(',') 37 | })) 38 | } 39 | 40 | return keycodes 41 | }, []) 42 | } 43 | -------------------------------------------------------------------------------- /app/src/keymap.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get' 2 | import keyBy from 'lodash/keyBy' 3 | export { loadKeymap } from './api' 4 | 5 | export function getBehaviourParams(parsedParams, behaviour) { 6 | const firstParsedParam = get(parsedParams, '[0]', {}) 7 | const commands = keyBy(behaviour.commands, 'code') 8 | return [].concat( 9 | behaviour.params, 10 | get(behaviour, 'params[0]') === 'command' 11 | ? get(commands[firstParsedParam.value], 'additionalParams', []) 12 | : [] 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/layout.js: -------------------------------------------------------------------------------- 1 | export { loadLayout } from './api' 2 | -------------------------------------------------------------------------------- /app/src/providers.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const DefinitionsContext = createContext({ 4 | keycodes: [], 5 | behaviours: [] 6 | }) 7 | 8 | export const SearchContext = createContext({ 9 | getSearchTargets: null 10 | }) 11 | -------------------------------------------------------------------------------- /app/src/styles.module.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/app/src/styles.module.css -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const api = require('./api') 2 | const config = require('./api/config') 3 | 4 | api.listen(config.PORT) 5 | console.log('listening on', config.PORT) 6 | -------------------------------------------------------------------------------- /keymap-editor-demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/keymap-editor-demo.mov -------------------------------------------------------------------------------- /old-readme.md: -------------------------------------------------------------------------------- 1 | # Keymap Editor 2 | 3 | A browser app (plus NodeJS server) to edit ZMK keymaps. This has been a solo 4 | project but in a workable state for quite a while now, and new features are in 5 | development all the time. 6 | 7 | **Try it live!** Go to the [keymap-editor] and try it out with the built-in 8 | [keymap-editor-demo-crkbd] before setting up your own repo. 9 | 10 | ![Screenshot](./screenshots/editor-screenshot.png) 11 | 12 | ## Features 13 | 14 | * WYSIWYG keymap editing 15 | * Multiple keymap sources: 16 | * GitHub repositories 17 | * Clipboard 18 | * Local file system (Chromium browsers only) 19 | * [Dark mode!](./screenshots/editor-screenshot-darkmode.png) 20 | * [Combo editing](./screenshots/editor-screenshot-combos.png) 21 | * [Macro editing](./screenshots/editor-screenshot-macros.png) 22 | * Behavior editing 23 | * Automatic layout generation for most keyboards available in the ZMK repo 24 | * Rotary encoders 25 | * Multiple keymaps 26 | 27 | _Read more: [Wiki:Features]_ 28 | 29 | ### In Progress 30 | 31 | There's a great deal of functionality present at the moment. As long as you're 32 | not obscuring the devicetree syntax by using custom preprocessor macros you can 33 | parse most of ZMK's functionality. 34 | 35 | Right now I'm working on cleaning up the codebase and refactoring to make the 36 | different pieces more reusable between the backend server and browser app. 37 | 38 | ### Planned features 39 | 40 | * **Keymap diagram export** I'd like to be able to reference keymap diagrams in 41 | the repository's `README.md` and have the editor update those diagrams upon 42 | comitting the changes. I'm searching for efficient ways to reuse the React 43 | components to generate SVG data instead but its tricky. 44 | 45 | #### What else? 46 | 47 | If you have thoughts on what needs to be fixed to support _your_ keyboard or to 48 | make this a useful tool for users, let me know. 49 | 50 | I'm not committing to taking this on myself, and as a hobbyist I don't have any 51 | commercially available keyboards to test out and provide specific support, but 52 | I'm happy to have discussions on where this (or another tool) can go. 53 | 54 | Do you have an idea you'd like to see implemented that might not work for this 55 | specific use case? _Talk to me_. I went to a lot of trouble building this and I 56 | can share a lot of that experience. Even if we don't have the same needs a lot 57 | of things can be supported modularly. 58 | 59 | 60 | ## Setup 61 | 62 | You've got a couple of options: 63 | 64 | ### Local 65 | 66 | You can clone this repo and your zmk-config and run the editor locally. Changes 67 | are saved to the keymap files in your local repository and you can commit and 68 | push them to as desired to trigger the GitHub Actions build. 69 | 70 | > **Note** 71 | > The code you're looking at here is very out-of-date compared to the deployed 72 | > web app. If you want to use this without depending on giving this app access 73 | > to your GitHub repository you can choose the app's _Clipboard_ or _FileSystem_ 74 | > keymap source. 75 | 76 | Read more about [local setup](running-locally.md) 77 | 78 | ### Web 79 | 80 | #### With local keymaps 81 | 82 | In the editor you can choose the _Clipboard_ keymap source and paste in the 83 | contents of your ZMK `.keymap` file, and if you're using a Chromium-based web 84 | browser you can alternatively use the _FileSystem_ source to read and make 85 | changes to select `.keymap` files directly. 86 | 87 | #### With your GitHub repositories 88 | 89 | This editor has a GitHub integration. You can load the web app and grant it 90 | access to your zmk-config repo. Changes to your keymap are committed right back 91 | to the repository so you only ever need to leave the app to download firmware. 92 | 93 | Try it now: 94 | 95 | 1. Make your own repo using the [keymap-editor-demo-crkbd template] on GitHub 96 | 2. Go to [keymap-editor] and authorize it to access your own repo. 97 | 98 | Read more about the [GitHub integration](api/services/github/README.md) 99 | 100 | 101 | ## License 102 | 103 | The code in this repo is available under the MIT license. 104 | 105 | The collection of ZMK keycodes is taken from the ZMK documentation under the MIT 106 | license as well. 107 | 108 | [keymap-editor]: https://nickcoutsos.github.io/keymap-editor/ 109 | [keymap-editor-demo-crkbd]: https://github.com/nickcoutsos/keymap-editor-demo-crkbd/ 110 | [keymap-editor-demo-crkbd template]: https://github.com/nickcoutsos/keymap-editor-demo-crkbd/generate 111 | [Wiki:Automatic Layout Generation]: https://github.com/nickcoutsos/keymap-editor/wiki/Defining-keyboard-layouts#automatic-layout-generation 112 | [Wiki:Features]: https://github.com/nickcoutsos/keymap-editor/wiki/Features 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keymap-editor", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "cd app && npm install", 8 | "start": "node index.js", 9 | "dev": "cross-env ENABLE_DEV_SERVER=true node index.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "axios": "^0.21.4", 17 | "body-parser": "^1.19.0", 18 | "cors": "^2.8.5", 19 | "cross-env": "^7.0.3", 20 | "dotenv": "^10.0.0", 21 | "express": "^4.17.1", 22 | "express-ws": "^4.0.0", 23 | "http-link-header": "^1.0.3", 24 | "jsonwebtoken": "^8.5.1", 25 | "lodash": "^4.17.21", 26 | "morgan": "^1.10.0" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^8.14.0", 30 | "eslint-config-standard": "^17.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /running-locally.md: -------------------------------------------------------------------------------- 1 | # Running Locally 2 | 3 | This tool was originally designed to help editing keymap files in repositories 4 | already cloned onto your computer. 5 | 6 | ## Setup 7 | 8 | 1. Clone this repo and open the new directory in a terminal. 9 | 2. Copy `.env.template` to `.env`. You can fill in this file as appropriate, but 10 | this is enough to get started. 11 | 4. Clone a `zmk-config`\* repo. Either create symlinks in this directory to the 12 | cloned repositories or clone them into this directory if you must. 13 | 3. Run `npm install` 14 | 4. Run `npm run dev` 15 | 5. Open `http://localhost:8080` in your browser. If a different port is needed 16 | set it in an environment variable when starting the server (e.g. 17 | `PORT=8081 node index.js`). 18 | 19 | \**The editor works using metadata files that describe the layout and keymap of 20 | the keyboard. This is based on JSON files used by QMK and Keyboard Layout Editor 21 | with some customization to generated human readable code as well. For an example 22 | see [zmk-config-corne-demo]* 23 | 24 | 25 | ## Using the editor 26 | 27 | Your selected keyboard should be loaded automatically. Click on the top-left 28 | corner of a key to change its bind behaviour, or in the middle to change the 29 | bind parameter. 30 | 31 | See also: [demo video](keymap-editor-demo.mov) 32 | 33 | Click the _Save Local_ button to save the modified keymap back to your local 34 | zmk-config repo. From here you can commit and push those changes to your remote 35 | on GitHub to trigger the build. 36 | 37 | [zmk-config-corne-demo]: https://github.com/nickcoutsos/zmk-config-corne-demo 38 | -------------------------------------------------------------------------------- /screenshots/editor-screenshot-combos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot-combos.png -------------------------------------------------------------------------------- /screenshots/editor-screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot-dark.png -------------------------------------------------------------------------------- /screenshots/editor-screenshot-darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot-darkmode.png -------------------------------------------------------------------------------- /screenshots/editor-screenshot-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot-light.png -------------------------------------------------------------------------------- /screenshots/editor-screenshot-macros.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot-macros.png -------------------------------------------------------------------------------- /screenshots/editor-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/editor-screenshot.png -------------------------------------------------------------------------------- /screenshots/layout-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nickcoutsos/keymap-editor/e7946792cf674db20ab72ab67fea759fff4c3ccc/screenshots/layout-example.png --------------------------------------------------------------------------------