"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[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 = {}; 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))); });