first commit
This commit is contained in:
361
cmd/hhkb-web/web/app.js
Normal file
361
cmd/hhkb-web/web/app.js
Normal file
@@ -0,0 +1,361 @@
|
||||
"use strict";
|
||||
|
||||
const $ = (sel, el = document) => el.querySelector(sel);
|
||||
const api = async (path, opts) => (await fetch(path, opts)).json();
|
||||
|
||||
const U = 54; // pixels per key unit
|
||||
const GAP = 4; // gap between keycaps
|
||||
|
||||
let DATA; // /api/init payload
|
||||
const modelsByName = {};
|
||||
const names = {}; // scancode -> label
|
||||
let model; // selected modelDTO
|
||||
let layer = "base"; // "base" | "fn"
|
||||
let selected = null; // selected key number
|
||||
const live = { base: {}, fn: {} }; // num -> code, from device/defaults
|
||||
const edits = { base: {}, fn: {} };// num -> pending code
|
||||
|
||||
let testMode = false; // key-test: highlight presses (focus-gated, no device needed)
|
||||
const pressed = new Set(); // key numbers currently held
|
||||
const keyEls = {}; // num -> DOM element
|
||||
let codeToNum = {}; // KeyboardEvent.code -> key number
|
||||
let defByNum = {}; // key number -> default (base) scancode
|
||||
let deviceReady = false; // auto-read already done since the device appeared
|
||||
|
||||
// HID usage -> KeyboardEvent.code (physical position, OS-layout independent).
|
||||
const USAGE_TO_CODE = (() => {
|
||||
const m = {};
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c, i) => (m[0x04 + i] = "Key" + c));
|
||||
for (let i = 0; i < 9; i++) m[0x1e + i] = "Digit" + (i + 1);
|
||||
m[0x27] = "Digit0";
|
||||
for (let i = 0; i < 12; i++) m[0x3a + i] = "F" + (i + 1);
|
||||
Object.assign(m, {
|
||||
0x28: "Enter", 0x29: "Escape", 0x2a: "Backspace", 0x2b: "Tab", 0x2c: "Space",
|
||||
0x2d: "Minus", 0x2e: "Equal", 0x2f: "BracketLeft", 0x30: "BracketRight", 0x31: "Backslash",
|
||||
0x33: "Semicolon", 0x34: "Quote", 0x35: "Backquote", 0x36: "Comma", 0x37: "Period",
|
||||
0x38: "Slash", 0x39: "CapsLock",
|
||||
0x46: "PrintScreen", 0x47: "ScrollLock", 0x48: "Pause",
|
||||
0x49: "Insert", 0x4a: "Home", 0x4b: "PageUp", 0x4c: "Delete", 0x4d: "End", 0x4e: "PageDown",
|
||||
0x4f: "ArrowRight", 0x50: "ArrowLeft", 0x51: "ArrowDown", 0x52: "ArrowUp",
|
||||
0xe0: "ControlLeft", 0xe1: "ShiftLeft", 0xe2: "AltLeft", 0xe3: "MetaLeft",
|
||||
0xe4: "ControlRight", 0xe5: "ShiftRight", 0xe6: "AltRight", 0xe7: "MetaRight",
|
||||
});
|
||||
return m;
|
||||
})();
|
||||
|
||||
// inverse: KeyboardEvent.code -> HID usage (names the key the keyboard sent).
|
||||
const CODE_TO_USAGE = Object.fromEntries(
|
||||
Object.entries(USAGE_TO_CODE).map(([usage, code]) => [code, Number(usage)])
|
||||
);
|
||||
|
||||
function codeName(code) {
|
||||
return names[code] ?? ("0x" + code.toString(16).padStart(2, "0"));
|
||||
}
|
||||
function shown(num) {
|
||||
if (num in edits[layer]) return edits[layer][num];
|
||||
if (num in live[layer]) return live[layer][num];
|
||||
return layer === "base" ? (defByNum[num] ?? 0) : 0;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
DATA = await api("/api/init");
|
||||
DATA.models.forEach(m => (modelsByName[m.name] = m));
|
||||
DATA.palette.forEach(g => g.keys.forEach(k => (names[k.code] = k.name)));
|
||||
DATA.models.forEach(m => m.keys.forEach(k => (names[k.def] = k.defname)));
|
||||
|
||||
buildModelSelect();
|
||||
buildLayers();
|
||||
buildPalette();
|
||||
|
||||
const want = DATA.device.model && modelsByName[DATA.device.model]
|
||||
? DATA.device.model : DATA.models[0].name;
|
||||
$("#model").value = want;
|
||||
selectModel(want);
|
||||
|
||||
// Auto-read on connect: read now if the keyboard is ready, otherwise keep
|
||||
// checking so it reads by itself the moment you plug in / grant access.
|
||||
if (!(await refreshDevice())) {
|
||||
const timer = setInterval(async () => {
|
||||
if (await refreshDevice()) clearInterval(timer);
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// refreshDevice updates the banner and, the first time the keyboard becomes
|
||||
// available, selects the detected model and reads its keymap. Returns whether
|
||||
// the device is currently connected.
|
||||
async function refreshDevice() {
|
||||
let dev;
|
||||
try { dev = await api("/api/device"); } catch { return false; }
|
||||
showBanner(dev);
|
||||
if (dev.connected && !deviceReady) {
|
||||
deviceReady = true;
|
||||
if (dev.model && modelsByName[dev.model] && $("#model").value !== dev.model) {
|
||||
$("#model").value = dev.model;
|
||||
selectModel(dev.model);
|
||||
}
|
||||
await readAll();
|
||||
}
|
||||
return dev.connected;
|
||||
}
|
||||
|
||||
function buildModelSelect() {
|
||||
const sel = $("#model");
|
||||
sel.innerHTML = "";
|
||||
DATA.models.forEach(m => {
|
||||
const o = document.createElement("option");
|
||||
o.value = m.name;
|
||||
o.textContent = m.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = () => selectModel(sel.value);
|
||||
}
|
||||
|
||||
function selectModel(name) {
|
||||
model = modelsByName[name];
|
||||
defByNum = {};
|
||||
model.keys.forEach(k => (defByNum[k.num] = k.def));
|
||||
codeToNum = buildCodeMap();
|
||||
selected = null;
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function buildLayers() {
|
||||
const box = $("#layers");
|
||||
box.innerHTML = "";
|
||||
[["base", "Base"], ["fn", "Fn"]].forEach(([id, label]) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = label;
|
||||
b.className = "layer" + (id === layer ? " active" : "");
|
||||
b.onclick = () => { layer = id; selected = null; buildLayers(); renderBoard(); };
|
||||
box.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const board = $("#board");
|
||||
board.innerHTML = "";
|
||||
Object.keys(keyEls).forEach(k => delete keyEls[k]);
|
||||
let maxX = 0, maxRow = 0;
|
||||
model.keys.forEach(k => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "key";
|
||||
keyEls[k.num] = d;
|
||||
if (k.num === selected) d.classList.add("selected");
|
||||
if (k.num in edits[layer]) d.classList.add("changed");
|
||||
if (pressed.has(k.num)) d.classList.add("pressed");
|
||||
d.style.left = (k.x * U) + "px";
|
||||
d.style.top = (k.row * U) + "px";
|
||||
d.style.width = (k.w * U - GAP) + "px";
|
||||
d.style.height = (U - GAP) + "px";
|
||||
d.textContent = codeName(shown(k.num));
|
||||
d.title = "key " + k.num;
|
||||
d.onclick = () => { selected = selected === k.num ? null : k.num; renderBoard(); };
|
||||
board.appendChild(d);
|
||||
maxX = Math.max(maxX, k.x + k.w);
|
||||
maxRow = Math.max(maxRow, k.row);
|
||||
});
|
||||
board.style.width = (maxX * U) + "px";
|
||||
board.style.height = ((maxRow + 1) * U) + "px";
|
||||
updateHint();
|
||||
}
|
||||
|
||||
function buildPalette() {
|
||||
const pal = $("#palette");
|
||||
pal.innerHTML = "";
|
||||
DATA.palette.forEach(g => {
|
||||
const sec = document.createElement("div");
|
||||
sec.className = "group";
|
||||
const h = document.createElement("div");
|
||||
h.className = "ghead";
|
||||
h.textContent = g.name;
|
||||
sec.appendChild(h);
|
||||
const row = document.createElement("div");
|
||||
row.className = "gkeys";
|
||||
g.keys.forEach(c => {
|
||||
const b = document.createElement("button");
|
||||
b.className = "cap";
|
||||
b.textContent = c.name;
|
||||
b.title = "0x" + c.code.toString(16).padStart(2, "0");
|
||||
b.onclick = () => assign(c.code);
|
||||
row.appendChild(b);
|
||||
});
|
||||
sec.appendChild(row);
|
||||
pal.appendChild(sec);
|
||||
});
|
||||
}
|
||||
|
||||
function assign(code) {
|
||||
if (selected == null) { toast("Select a key first"); return; }
|
||||
if ((live[layer][selected] ?? 0) === code) delete edits[layer][selected];
|
||||
else edits[layer][selected] = code;
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function buildCodeMap() {
|
||||
const m = {};
|
||||
model.keys.forEach(k => {
|
||||
const code = USAGE_TO_CODE[live.base[k.num] ?? k.def];
|
||||
if (code) m[code] = k.num;
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
// Focus-gated: we listen only while this page has focus, never in the background.
|
||||
function setTest(on) {
|
||||
testMode = on;
|
||||
$("#test").classList.toggle("active", on);
|
||||
const recv = $("#recv");
|
||||
if (on) {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
recv.hidden = false;
|
||||
recv.textContent = "Press a key. (The Fn key is handled inside the keyboard and never reaches the computer.)";
|
||||
toast("Key test ON — only while this page is focused. Click again to stop.");
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
pressed.forEach(n => keyEls[n] && keyEls[n].classList.remove("pressed"));
|
||||
pressed.clear();
|
||||
recv.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const usage = CODE_TO_USAGE[e.code];
|
||||
if (usage != null && !(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault();
|
||||
$("#recv").textContent = "Keyboard sent: " + (usage != null ? codeName(usage) : e.code);
|
||||
const num = codeToNum[e.code];
|
||||
if (num == null) return; // e.g. an Fn-layer result like Up has no base position
|
||||
pressed.add(num);
|
||||
if (keyEls[num]) keyEls[num].classList.add("pressed");
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
const num = codeToNum[e.code];
|
||||
if (num == null) return;
|
||||
pressed.delete(num);
|
||||
if (keyEls[num]) keyEls[num].classList.remove("pressed");
|
||||
}
|
||||
|
||||
// baseLabel is what's printed on the physical key (its base-layer assignment).
|
||||
function baseLabel(num) {
|
||||
return codeName(live.base[num] ?? defByNum[num] ?? 0);
|
||||
}
|
||||
|
||||
function updateHint() {
|
||||
const h = $("#hint");
|
||||
const n = changedNums().length;
|
||||
const pend = n ? ` • ${n} unsaved (press Write)` : "";
|
||||
if (selected == null) {
|
||||
h.textContent = (layer === "fn"
|
||||
? "Fn layer — click a key to set what “Fn + that key” sends."
|
||||
: "Click a key, then pick a keycode below.") + pend;
|
||||
return;
|
||||
}
|
||||
const lhs = layer === "fn" ? `Fn + ${baseLabel(selected)}` : baseLabel(selected);
|
||||
h.textContent = `${lhs} → ${codeName(shown(selected))}${pend}`;
|
||||
}
|
||||
|
||||
function changedNums() { return Object.keys(edits[layer]).map(Number); }
|
||||
|
||||
async function write() {
|
||||
const nums = changedNums();
|
||||
if (nums.length === 0) { toast("No changes to write"); return; }
|
||||
for (const num of nums) {
|
||||
const res = await api("/api/remap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ num, code: edits[layer][num], fn: layer === "fn" }),
|
||||
});
|
||||
if (res.error) { toast(res.error); return; }
|
||||
live[layer][num] = edits[layer][num];
|
||||
delete edits[layer][num];
|
||||
renderBoard();
|
||||
}
|
||||
toast("Written to keyboard");
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
for (const lyr of ["base", "fn"]) {
|
||||
const res = await api("/api/keymap?layer=" + lyr);
|
||||
if (res.error) { toast(res.error); return; }
|
||||
live[lyr] = {};
|
||||
res.keys.forEach(k => { live[lyr][k.num] = k.code; names[k.code] = k.name; });
|
||||
}
|
||||
edits.base = {};
|
||||
edits.fn = {};
|
||||
codeToNum = buildCodeMap();
|
||||
renderBoard();
|
||||
toast("Read from keyboard");
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (!confirm("Restore the factory default keymap on the keyboard?")) return;
|
||||
const res = await api("/api/reset", { method: "POST" });
|
||||
if (res.error) { toast(res.error); return; }
|
||||
await readAll();
|
||||
toast("Reset to factory defaults");
|
||||
}
|
||||
|
||||
function save() {
|
||||
const data = { model: model.name, base: {}, fn: {} };
|
||||
["base", "fn"].forEach(l =>
|
||||
model.keys.forEach(k => {
|
||||
const e = edits[l], li = live[l];
|
||||
data[l][k.num] = (k.num in e) ? e[k.num] : (li[k.num] ?? 0);
|
||||
}));
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "hkkb-keymap.json";
|
||||
a.click();
|
||||
}
|
||||
|
||||
function load(file) {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(r.result);
|
||||
["base", "fn"].forEach(l => {
|
||||
for (const num in (data[l] || {})) {
|
||||
if (!(num in defByNum)) continue; // ignore keys not on this model
|
||||
const code = data[l][num];
|
||||
const cur = live[l][num] ?? (l === "base" ? defByNum[num] : 0);
|
||||
if (cur !== code) edits[l][num] = code;
|
||||
}
|
||||
});
|
||||
renderBoard();
|
||||
toast("Loaded — review the changes, then Write");
|
||||
} catch (e) {
|
||||
toast("Bad file: " + e);
|
||||
}
|
||||
};
|
||||
r.readAsText(file);
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
const t = $("#toast");
|
||||
t.textContent = msg;
|
||||
t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (t.hidden = true), 3500);
|
||||
}
|
||||
|
||||
function showBanner(dev) {
|
||||
const b = $("#banner");
|
||||
if (dev.connected) { b.hidden = true; return; }
|
||||
b.hidden = false;
|
||||
b.textContent = dev.error || "No keyboard connected — showing factory defaults.";
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
$("#read").onclick = readAll;
|
||||
$("#write").onclick = write;
|
||||
$("#reset").onclick = reset;
|
||||
$("#save").onclick = save;
|
||||
$("#load").onclick = () => $("#file").click();
|
||||
$("#file").onchange = e => e.target.files[0] && load(e.target.files[0]);
|
||||
$("#test").onclick = () => setTest(!testMode);
|
||||
boot().catch(e => toast(String(e)));
|
||||
});
|
||||
Reference in New Issue
Block a user