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 | You need to enable JavaScript to run this app.
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 |
97 | Save Local
98 |
99 | )}
100 | {source === 'github' && (
101 |
106 | {saving ? 'Saving' : 'Commit Changes'}
107 | {saving && }
108 |
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 |
23 | {dismissText}
24 |
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 |
6 | {text || children}
7 |
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 |
31 | {label}
32 |
33 |
34 | {choices.map(({ name }, i) => (
35 | {name}
36 | ))}
37 |
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 |
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 | Show all
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 | 
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
--------------------------------------------------------------------------------