601 lines
19 KiB
JavaScript
601 lines
19 KiB
JavaScript
|
|
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 = `<?xml version="1.0" encoding="UTF-8"?>\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();
|