const state = { rows: 6, cols: 16, pitch: 19.05, inset: 0.48, radius: 1, margin: 3, bg: "#000000", fontFamily: "'Roboto Condensed', 'Roboto', Arial Narrow, Arial, Helvetica, sans-serif", fontSizeMm: 3.8, keys: [], selected: null }; const ns = "http://www.w3.org/2000/svg"; const FONT_STRETCH = "70%"; const KLE_FS_MM = { 0: 4.5, 1: 3.0, 2: 3.8, 3: 4.5, 4: 5.3, 5: 6.0, 6: 6.8, 7: 7.5, 8: 8.3, 9: 9.0 }; const LABEL_POS = { 0: [0, 0], 1: [0, 2], 2: [2, 0], 3: [2, 2], 4: [0, 3], 5: [2, 3], 6: [0, 1], 7: [2, 1], 8: [1, 0], 9: [1, 1], 10: [1, 2], 11: [1, 3] }; const VISUAL_SLOT_TO_KLE = [0, 8, 2, 6, 9, 7, 1, 10, 3]; const el = { rows: document.getElementById("rows"), cols: document.getElementById("cols"), pitch: document.getElementById("pitch"), inset: document.getElementById("inset"), radius: document.getElementById("radius"), margin: document.getElementById("margin"), bg: document.getElementById("bg"), font: document.getElementById("font"), fontSize: document.getElementById("fontSize"), makeGridBtn: document.getElementById("makeGridBtn"), downloadSvgBtn: document.getElementById("downloadSvgBtn"), downloadJsonBtn: document.getElementById("downloadJsonBtn"), loadJsonInput: document.getElementById("loadJsonInput"), keyLabel: document.getElementById("keyLabel"), keyColor: document.getElementById("keyColor"), keyTextColor: document.getElementById("keyTextColor"), keyW: document.getElementById("keyW"), keyH: document.getElementById("keyH"), keyFontScale: document.getElementById("keyFontScale"), keyAlign: document.getElementById("keyAlign"), applyKeyBtn: document.getElementById("applyKeyBtn"), clearKeyBtn: document.getElementById("clearKeyBtn"), selectedHint: document.getElementById("selectedHint"), svgWrap: document.getElementById("svgWrap") }; el.legendTextInputs = Array.from({ length: 9 }, (_, i) => document.getElementById(`legendText${i}`)); el.legendSizeInputs = Array.from({ length: 9 }, (_, i) => document.getElementById(`legendSize${i}`)); function wrapText(text, availMm, fmm) { const charW = fmm * 0.60; if (text.length * charW <= availMm) return [text]; const maxChars = Math.max(Math.floor(availMm / charW), 1); const words = text.split(" "); const lines = []; let cur = ""; for (const w of words) { const trial = cur ? `${cur} ${w}` : w; if (trial.length <= maxChars || !cur) { cur = trial; } else { lines.push(cur); cur = w; } } if (cur) lines.push(cur); return lines; } function decodeEntities(text) { const ta = document.createElement("textarea"); ta.innerHTML = text; return ta.value; } function makeDefaultGrid() { state.keys = []; for (let y = 0; y < state.rows; y++) { for (let x = 0; x < state.cols; x++) { state.keys.push({ id: `${x},${y}`, x, y, w: 1, h: 1, label: "", fill: "#d0ccc0", text: "#000000", fontScale: 1 }); } } state.selected = null; } function getBoardSizeMm() { if (!Array.isArray(state.keys)) { state.keys = []; } let maxX = 0; let maxY = 0; for (const k of state.keys) { maxX = Math.max(maxX, k.x + k.w); maxY = Math.max(maxY, k.y + k.h); } return { w: maxX * state.pitch + state.margin * 2, h: maxY * state.pitch + state.margin * 2 }; } function buildSvg() { const board = getBoardSizeMm(); const svg = document.createElementNS(ns, "svg"); svg.setAttribute("xmlns", ns); svg.setAttribute("viewBox", `0 0 ${board.w.toFixed(2)} ${board.h.toFixed(2)}`); svg.setAttribute("width", `${board.w.toFixed(2)}mm`); svg.setAttribute("height", `${board.h.toFixed(2)}mm`); const defs = document.createElementNS(ns, "defs"); const style = document.createElementNS(ns, "style"); style.textContent = "@font-face {font-family:'Roboto Condensed';font-style:normal;font-weight:400;font-stretch:75%;src:url('https://fonts.gstatic.com/s/roboto/v51/KFOMCnqEu92Fr1ME7kSn66aGLdTylR4MQXC89YmC2DPNWubEbVmUiAo.woff2') format('woff2');}"; defs.appendChild(style); svg.appendChild(defs); const bgRect = document.createElementNS(ns, "rect"); bgRect.setAttribute("x", "0"); bgRect.setAttribute("y", "0"); bgRect.setAttribute("width", board.w.toFixed(2)); bgRect.setAttribute("height", board.h.toFixed(2)); bgRect.setAttribute("fill", state.bg); svg.appendChild(bgRect); const root = document.createElementNS(ns, "g"); root.setAttribute("transform", `translate(${state.margin},${state.margin})`); root.setAttribute("font-family", state.fontFamily); root.setAttribute("font-stretch", FONT_STRETCH); svg.appendChild(root); for (const key of state.keys) { const x = key.x * state.pitch + state.inset; const y = key.y * state.pitch + state.inset; const w = key.w * state.pitch - state.inset * 2; const h = key.h * state.pitch - state.inset * 2; const r = document.createElementNS(ns, "rect"); r.setAttribute("x", x.toFixed(2)); r.setAttribute("y", y.toFixed(2)); r.setAttribute("width", w.toFixed(2)); r.setAttribute("height", h.toFixed(2)); r.setAttribute("rx", state.radius.toString()); r.setAttribute("ry", state.radius.toString()); r.setAttribute("fill", key.fill); r.setAttribute("stroke", "#2f2f2f"); r.setAttribute("stroke-width", "0.2"); r.classList.add("key-hit"); if (state.selected && state.selected.id === key.id) { r.classList.add("selected"); } r.addEventListener("click", () => { state.selected = key; syncSelectedForm(); render(); }); root.appendChild(r); if (Array.isArray(key.legends) && key.legends.length) { renderKleLegends(root, key, x, y, w, h); } else if (key.label.trim()) { const text = document.createElementNS(ns, "text"); const lines = key.label.split("\n"); const f = (state.fontSizeMm * key.fontScale); const lineHeight = f * 1.2; const totalH = lines.length * lineHeight; const tx = x + w / 2; const startY = y + (h - totalH) / 2 + f * 0.85; text.setAttribute("x", tx.toFixed(2)); text.setAttribute("y", startY.toFixed(2)); text.setAttribute("fill", key.text); text.setAttribute("font-size", f.toFixed(2)); text.setAttribute("text-anchor", "middle"); text.setAttribute("font-weight", "700"); lines.forEach((line, i) => { const tspan = document.createElementNS(ns, "tspan"); tspan.setAttribute("x", tx.toFixed(2)); tspan.setAttribute("dy", i === 0 ? "0" : lineHeight.toFixed(2)); tspan.textContent = line; text.appendChild(tspan); }); root.appendChild(text); } } return svg; } function renderKleLegends(root, key, x, y, w, h) { const pad = 1.5; const centered = key.align === 7; const availW = w - 2 * pad; const nonEmpty = []; for (let i = 0; i < Math.min(12, key.legends.length); i++) { if ((key.legends[i] || "").trim()) nonEmpty.push(i); } if (centered && nonEmpty.length === 1) { const idx = nonEmpty[0]; const raw = (key.legends[idx] || "").trim(); const fmm = Array.isArray(key.legendFontMm) && Number.isFinite(key.legendFontMm[idx]) ? key.legendFontMm[idx] : state.fontSizeMm; const lines = wrapText(raw, availW, fmm); const totalH = lines.length * fmm * 1.25; const startY = y + (h - totalH) / 2 + fmm * 0.9; const cx = x + w / 2; lines.forEach((ln, li) => { const text = document.createElementNS(ns, "text"); text.setAttribute("x", cx.toFixed(2)); text.setAttribute("y", (startY + li * fmm * 1.25).toFixed(2)); text.setAttribute("fill", key.text); text.setAttribute("font-size", fmm.toFixed(2)); text.setAttribute("text-anchor", "middle"); text.setAttribute("font-weight", "700"); text.textContent = ln; root.appendChild(text); }); return; } for (let i = 0; i < Math.min(12, key.legends.length); i++) { const legend = (key.legends[i] || "").trim(); if (!legend) continue; const pos = LABEL_POS[i]; if (!pos) continue; const [col, row] = pos; if (row === 3) continue; const fmm = Array.isArray(key.legendFontMm) && Number.isFinite(key.legendFontMm[i]) ? key.legendFontMm[i] : state.fontSizeMm; let tx; let anchor; if (centered) { tx = x + w / 2; anchor = "middle"; } else if (col === 0) { tx = x + pad; anchor = "start"; } else if (col === 1) { tx = x + w / 2; anchor = "middle"; } else { tx = x + w - pad; anchor = "end"; } let ty = y + pad + fmm * 0.85; if (row === 1) { ty = y + h / 2 + fmm * 0.35; } else if (row === 2) { ty = y + h - pad; } const lines = wrapText(legend, availW, fmm); if (lines.length === 1) { const text = document.createElementNS(ns, "text"); text.setAttribute("x", tx.toFixed(2)); text.setAttribute("y", ty.toFixed(2)); text.setAttribute("fill", key.text); text.setAttribute("font-size", fmm.toFixed(2)); text.setAttribute("text-anchor", anchor); text.setAttribute("font-weight", "700"); text.textContent = lines[0]; root.appendChild(text); } else { const text = document.createElementNS(ns, "text"); text.setAttribute("x", tx.toFixed(2)); text.setAttribute("y", ty.toFixed(2)); text.setAttribute("fill", key.text); text.setAttribute("font-size", fmm.toFixed(2)); text.setAttribute("text-anchor", anchor); text.setAttribute("font-weight", "700"); lines.forEach((ln, li) => { const tspan = document.createElementNS(ns, "tspan"); tspan.setAttribute("x", tx.toFixed(2)); tspan.setAttribute("dy", li === 0 ? "0" : (fmm * 1.25).toFixed(2)); tspan.textContent = ln; text.appendChild(tspan); }); root.appendChild(text); } } } function render() { el.svgWrap.innerHTML = ""; const svg = buildSvg(); el.svgWrap.appendChild(svg); } function syncStateFromControls() { state.rows = parseInt(el.rows.value, 10) || 1; state.cols = parseInt(el.cols.value, 10) || 1; state.pitch = parseFloat(el.pitch.value) || 19.05; state.inset = parseFloat(el.inset.value) || 0; state.radius = parseFloat(el.radius.value) || 0; state.margin = parseFloat(el.margin.value) || 0; state.bg = el.bg.value; state.fontFamily = el.font.value.trim() || "'Roboto Condensed', 'Roboto', Arial Narrow, Arial, Helvetica, sans-serif"; state.fontSizeMm = parseFloat(el.fontSize.value) || 3.8; } function syncSelectedForm() { if (!state.selected) { el.selectedHint.textContent = "Click a key in the preview."; return; } el.selectedHint.textContent = `Selected key at (${state.selected.x}, ${state.selected.y})`; el.keyLabel.value = state.selected.label; el.keyColor.value = state.selected.fill; el.keyTextColor.value = state.selected.text; el.keyW.value = state.selected.w; el.keyH.value = state.selected.h; el.keyFontScale.value = state.selected.fontScale; const legends = Array.isArray(state.selected.legends) ? state.selected.legends : []; const sizeIdx = Array.isArray(state.selected.legendSizeIdx) ? state.selected.legendSizeIdx : []; for (let vi = 0; vi < 9; vi++) { const kleSlot = VISUAL_SLOT_TO_KLE[vi]; el.legendTextInputs[vi].value = legends[kleSlot] || ""; el.legendSizeInputs[vi].value = Number.isFinite(sizeIdx[kleSlot]) ? sizeIdx[kleSlot] : 3; } el.keyAlign.value = Number.isFinite(state.selected.align) ? state.selected.align : 4; } function updateSelectedKeyFromInputs() { if (!state.selected) return; state.selected.label = decodeEntities(el.keyLabel.value); state.selected.fill = el.keyColor.value; state.selected.text = el.keyTextColor.value; state.selected.w = Math.max(0.25, parseFloat(el.keyW.value) || 1); state.selected.h = Math.max(0.25, parseFloat(el.keyH.value) || 1); state.selected.fontScale = Math.max(0.5, parseFloat(el.keyFontScale.value) || 1); const legends = new Array(12).fill(""); const sizeIdx = new Array(12).fill(3); for (let vi = 0; vi < 9; vi++) { const kleSlot = VISUAL_SLOT_TO_KLE[vi]; legends[kleSlot] = decodeEntities(el.legendTextInputs[vi].value || ""); const s = parseInt(el.legendSizeInputs[vi].value, 10); sizeIdx[kleSlot] = Number.isFinite(s) ? Math.max(0, Math.min(9, s)) : 3; } state.selected.legends = legends; state.selected.legendSizeIdx = sizeIdx; state.selected.legendFontMm = legends.map((_, i) => { const idx = Number.isFinite(sizeIdx[i]) && sizeIdx[i] > 0 ? sizeIdx[i] : 3; return KLE_FS_MM[idx] || KLE_FS_MM[3]; }); state.selected.align = Math.max(0, Math.min(7, parseInt(el.keyAlign.value, 10) || 4)); state.selected.label = legends.filter(Boolean).join(" "); } function downloadTextFile(name, content, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function wireEvents() { [ el.rows, el.cols, el.pitch, el.inset, el.radius, el.margin, el.bg, el.font, el.fontSize ].forEach(node => node.addEventListener("input", () => { syncStateFromControls(); render(); })); el.makeGridBtn.addEventListener("click", () => { syncStateFromControls(); makeDefaultGrid(); syncSelectedForm(); render(); }); el.applyKeyBtn.addEventListener("click", () => { if (!state.selected) return; updateSelectedKeyFromInputs(); render(); }); [ el.keyLabel, el.keyColor, el.keyTextColor, el.keyW, el.keyH, el.keyFontScale, el.keyAlign ].forEach(node => node.addEventListener("input", () => { if (!state.selected) return; updateSelectedKeyFromInputs(); render(); })); el.legendTextInputs.forEach(node => node.addEventListener("input", () => { if (!state.selected) return; updateSelectedKeyFromInputs(); render(); })); el.legendSizeInputs.forEach(node => node.addEventListener("input", () => { if (!state.selected) return; updateSelectedKeyFromInputs(); render(); })); el.clearKeyBtn.addEventListener("click", () => { if (!state.selected) return; state.selected.label = ""; state.selected.legends = new Array(12).fill(""); state.selected.legendSizeIdx = new Array(12).fill(3); state.selected.legendFontMm = new Array(12).fill(KLE_FS_MM[3]); el.keyLabel.value = ""; for (let vi = 0; vi < 9; vi++) { el.legendTextInputs[vi].value = ""; el.legendSizeInputs[vi].value = 3; } render(); }); el.downloadSvgBtn.addEventListener("click", () => { const svg = buildSvg(); const content = `\n${svg.outerHTML}\n`; downloadTextFile("keyboard-layout.svg", content, "image/svg+xml"); }); el.downloadJsonBtn.addEventListener("click", () => { const payload = { rows: state.rows, cols: state.cols, pitch: state.pitch, inset: state.inset, radius: state.radius, margin: state.margin, bg: state.bg, fontFamily: state.fontFamily, fontSizeMm: state.fontSizeMm, keys: state.keys }; downloadTextFile("keyboard-layout.json", JSON.stringify(payload, null, 2), "application/json"); }); el.loadJsonInput.addEventListener("change", async (ev) => { const file = ev.target.files && ev.target.files[0]; if (!file) return; try { const text = await file.text(); const loaded = JSON.parse(text); loadAnyJson(loaded); syncSelectedForm(); render(); } catch (err) { console.error(err); alert("Could not load JSON. Expected editor JSON or KLE JSON."); } finally { el.loadJsonInput.value = ""; } }); } function loadAnyJson(loaded) { if (Array.isArray(loaded)) { loadKleJson(loaded); return; } loadEditorJson(loaded); } function loadEditorJson(loaded) { state.rows = Math.max(1, parseInt(loaded.rows, 10) || 6); state.cols = Math.max(1, parseInt(loaded.cols, 10) || 16); state.pitch = Number.isFinite(loaded.pitch) ? loaded.pitch : 19.05; state.inset = Number.isFinite(loaded.inset) ? loaded.inset : 0.48; state.radius = Number.isFinite(loaded.radius) ? loaded.radius : 1; state.margin = Number.isFinite(loaded.margin) ? loaded.margin : 3; state.bg = loaded.bg || "#000000"; state.fontFamily = loaded.fontFamily || "'Roboto Condensed', 'Roboto', Arial Narrow, Arial, Helvetica, sans-serif"; state.fontSizeMm = Number.isFinite(loaded.fontSizeMm) ? loaded.fontSizeMm : 3.8; state.keys = Array.isArray(loaded.keys) ? loaded.keys : []; state.selected = null; syncControlsFromState(); } function loadKleJson(data) { const meta = (data[0] && typeof data[0] === "object" && !Array.isArray(data[0])) ? data[0] : {}; const rows = data.filter(Array.isArray); const keys = []; let cy = 0; let maxCol = 0; // KLE properties are sticky across rows. let c = "#d0ccc0"; let t = "#000000"; let f = 1; let fa = []; let a = 4; for (const row of rows) { let cx = 0; let w = 1; let h = 1; let x = 0; let y = 0; for (const item of row) { if (typeof item === "string") { cx += x; cy += y; const legends = item.split("\n").map(decodeEntities); const legendFontMm = []; const legendSizeIdx = []; for (let li = 0; li < legends.length; li++) { const sizeIdx = Array.isArray(fa) && Number.isFinite(fa[li]) && fa[li] > 0 ? fa[li] : f; legendSizeIdx[li] = sizeIdx; legendFontMm[li] = KLE_FS_MM[sizeIdx] || KLE_FS_MM[3]; } keys.push({ id: `${cx},${cy},${keys.length}`, x: cx, y: cy, w, h, label: legends.filter(Boolean).join(" "), legends, legendSizeIdx, legendFontMm, align: a, fill: c, text: t, fontScale: 1 }); cx += w; maxCol = Math.max(maxCol, cx); w = 1; h = 1; x = 0; y = 0; } else if (item && typeof item === "object" && !Array.isArray(item)) { c = item.c || c; t = item.t || t; if (Number.isFinite(item.a)) a = item.a; if (Number.isFinite(item.f)) { f = item.f; fa = []; } if (Array.isArray(item.fa)) { fa = item.fa; } if (Number.isFinite(item.w)) w = item.w; if (Number.isFinite(item.h)) h = item.h; if (Number.isFinite(item.x)) x = item.x; if (Number.isFinite(item.y)) y = item.y; } } cy += 1; } state.pitch = 19.05; state.inset = 0.48; state.radius = 1; state.margin = 3; state.bg = meta.backcolor || "#000000"; state.fontFamily = "'Roboto Condensed', 'Roboto', Arial Narrow, Arial, Helvetica, sans-serif"; state.fontSizeMm = 3.8; state.rows = Math.max(1, Math.ceil(Math.max(...keys.map(k => k.y + k.h), 1))); state.cols = Math.max(1, Math.ceil(maxCol || 1)); state.keys = keys; state.selected = null; syncControlsFromState(); } function syncControlsFromState() { el.rows.value = state.rows; el.cols.value = state.cols; el.pitch.value = state.pitch; el.inset.value = state.inset; el.radius.value = state.radius; el.margin.value = state.margin; el.bg.value = state.bg; el.font.value = state.fontFamily; el.fontSize.value = state.fontSizeMm; } syncStateFromControls(); makeDefaultGrid(); wireEvents(); render();