diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html
index 28fb1dacec1b..6062b40573b8 100644
--- a/frontend/src/html/pages/settings.html
+++ b/frontend/src/html/pages/settings.html
@@ -1236,6 +1236,59 @@
+
+
+
+
diff --git a/frontend/src/styles/keymap.scss b/frontend/src/styles/keymap.scss
index 078df6d6ceb8..ec46c4635716 100644
--- a/frontend/src/styles/keymap.scss
+++ b/frontend/src/styles/keymap.scss
@@ -544,4 +544,39 @@
position: relative;
}
}
+ &.custom {
+ grid-template-rows: 2rem 2rem 2rem 2rem;
+ .r1,
+ .r2,
+ .r3,
+ .r4,
+ .r5,
+ .r6 {
+ display: grid;
+ justify-content: start;
+ gap: 0;
+ }
+
+ .keyboard-grid {
+ display: grid;
+ grid-template-columns: repeat(var(--columns), var(--colSpan));
+ grid-template-rows: repeat(var(--rows), var(--rowSpan));
+ }
+
+ .keymapKey {
+ width: 100%;
+ height: auto;
+ margin: 0.125rem;
+ }
+
+ .keySpace {
+ font-size: 0.5rem;
+ }
+
+ .keymapSplitSpacer {
+ display: block;
+ width: 2rem;
+ height: 2rem;
+ }
+ }
}
diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss
index f1a923d2722f..7f694d8f88ac 100644
--- a/frontend/src/styles/settings.scss
+++ b/frontend/src/styles/settings.scss
@@ -144,6 +144,29 @@
}
}
+ .textareaAndButton {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+
+ span {
+ display: flex;
+ gap: 0.5rem;
+ }
+ .button {
+ height: auto;
+
+ .fas {
+ margin-right: 0rem;
+ }
+ }
+ .textarea {
+ height: 200px;
+ resize: none;
+ }
+ }
+
.rangeGroup {
display: grid;
grid-template-columns: auto 1fr;
@@ -169,6 +192,7 @@
}
&[data-config-name="fontFamily"],
+ &[data-config-name="keymapCustom"],
&[data-config-name="customBackgroundSize"] {
.separator {
margin-bottom: 0.5rem;
@@ -187,6 +211,7 @@
}
}
+ &[data-config-name="keymapCustom"],
&[data-config-name="fontFamily"] {
grid-template-areas:
"title title"
@@ -198,6 +223,7 @@
.separator {
margin-bottom: 0;
}
+ .usingLocalKeymap,
.usingLocalFont {
button {
width: 100%;
diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts
index 4cc09cad4f56..7e5fdec2061f 100644
--- a/frontend/src/ts/commandline/commandline-metadata.ts
+++ b/frontend/src/ts/commandline/commandline-metadata.ts
@@ -26,7 +26,8 @@ type ConfigKeysWithoutCommands =
| "autoSwitchTheme"
| "themeLight"
| "themeDark"
- | "burstHeatmap";
+ | "burstHeatmap"
+ | "keymapCustom";
type SkippedConfigKeys =
| "minBurst" //this is skipped for now because it has 2 nested inputs;
diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts
index bd4d2931425e..e489de27c300 100644
--- a/frontend/src/ts/commandline/lists.ts
+++ b/frontend/src/ts/commandline/lists.ts
@@ -6,6 +6,7 @@ import NavigationCommands from "./lists/navigation";
import ResultScreenCommands from "./lists/result-screen";
import CustomBackgroundCommands from "./lists/custom-background";
import FontFamilyCommands from "./lists/font-family";
+import KeymapStyleCommands from "./lists/keymap-style";
import CustomBackgroundFilterCommands from "./lists/background-filter";
import AddOrRemoveThemeToFavorite from "./lists/add-or-remove-theme-to-favorites";
import TagsCommands from "./lists/tags";
@@ -178,7 +179,7 @@ export const commands: CommandsSubgroup = {
"fontSize",
...FontFamilyCommands,
"keymapMode",
- "keymapStyle",
+ ...KeymapStyleCommands,
"keymapLegendStyle",
"keymapSize",
keymapLayoutCommand,
diff --git a/frontend/src/ts/commandline/lists/keymap-style.ts b/frontend/src/ts/commandline/lists/keymap-style.ts
new file mode 100644
index 000000000000..77b868f506e4
--- /dev/null
+++ b/frontend/src/ts/commandline/lists/keymap-style.ts
@@ -0,0 +1,48 @@
+import { buildCommandForConfigKey } from "../util";
+import { KeymapCustom } from "@monkeytype/schemas/configs";
+import { stringToKeymap } from "../../utils/custom-keymap";
+import * as UpdateConfig from "../../config";
+import * as TestLogic from "../../test/test-logic";
+import { Command } from "../types";
+
+const fromMeta = buildCommandForConfigKey("keymapStyle");
+
+if (fromMeta.subgroup) {
+ const indexCustom = fromMeta.subgroup.list.findIndex(
+ (command) => command.id === "setKeymapStyleCustom"
+ );
+ fromMeta.subgroup.list.splice(indexCustom, 1, {
+ id: "setKeymapStyleCustom",
+ display: "custom...",
+ configValue: "custom",
+ icon: "fa-keyboard",
+ subgroup: {
+ title: "Set custom keymap?",
+ list: [
+ {
+ id: "setKeymapStyleCustomNew",
+ display: "new keymap",
+ input: true,
+ exec: ({ input }) => {
+ if (input === undefined || input === "") return;
+ const keymap: KeymapCustom = stringToKeymap(input);
+ UpdateConfig.setKeymapCustom(keymap);
+ UpdateConfig.setKeymapStyle("custom");
+ TestLogic.restart();
+ },
+ },
+ {
+ id: "setKeymapStyleCustomLoad",
+ display: "load keymap",
+ exec: () => {
+ UpdateConfig.setKeymapStyle("custom");
+ },
+ },
+ ],
+ },
+ });
+}
+
+const commands: Command[] = [fromMeta];
+
+export default commands;
diff --git a/frontend/src/ts/commandline/util.ts b/frontend/src/ts/commandline/util.ts
index 347baeddc49f..451efb0f9359 100644
--- a/frontend/src/ts/commandline/util.ts
+++ b/frontend/src/ts/commandline/util.ts
@@ -1,6 +1,6 @@
import Config, { genericSet } from "../config";
import { ConfigMetadata, configMetadata } from "../config-metadata";
-import { capitalizeFirstLetter } from "../utils/strings";
+import { capitalizeFirstLetter, stringifyConfigValue } from "../utils/strings";
import {
CommandlineConfigMetadata,
commandlineConfigMetadata,
@@ -171,13 +171,13 @@ function buildSubgroupCommand
(
} else if (value === false) {
displayString = "off";
} else {
- displayString = value.toString();
+ displayString = stringifyConfigValue(value);
}
}
return {
id: `set${capitalizeFirstLetter(key)}${capitalizeFirstLetter(
- val.toString()
+ stringifyConfigValue(val)
)}`,
display: displayString,
configValueMode: commandConfigValueMode?.(value),
@@ -223,7 +223,8 @@ function buildInputCommand({
const result = {
id: `set${capitalizeFirstLetter(key)}Custom`,
defaultValue:
- inputProps?.defaultValue ?? (() => Config[key]?.toString() ?? ""),
+ inputProps?.defaultValue ??
+ (() => stringifyConfigValue(Config[key]) ?? ""),
configValue:
inputProps !== undefined && "configValue" in inputProps
? inputProps.configValue ?? undefined
diff --git a/frontend/src/ts/config-metadata.ts b/frontend/src/ts/config-metadata.ts
index cbd64a2390e2..bb65f4bb0b18 100644
--- a/frontend/src/ts/config-metadata.ts
+++ b/frontend/src/ts/config-metadata.ts
@@ -583,6 +583,13 @@ export const configMetadata: ConfigMetadataObject = {
overrideConfig: ({ currentConfig }) =>
currentConfig.keymapMode === "off" ? { keymapMode: "static" } : {},
},
+ keymapCustom: {
+ icon: "fa-keyboard",
+ displayString: "keymap custom",
+ changeRequiresRestart: false,
+ overrideConfig: ({ currentConfig }) =>
+ currentConfig.keymapMode === "off" ? { keymapMode: "static" } : {},
+ },
keymapLegendStyle: {
icon: "fa-keyboard",
displayString: "keymap legend style",
diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts
index cf732b09f53a..207329d856d4 100644
--- a/frontend/src/ts/config.ts
+++ b/frontend/src/ts/config.ts
@@ -27,6 +27,7 @@ import { ZodSchema } from "zod";
import * as TestState from "./test/test-state";
import { ConfigMetadataObject, configMetadata } from "./config-metadata";
import { FontName } from "@monkeytype/schemas/fonts";
+import { stringifyConfigValue } from "./utils/strings";
const configLS = new LocalStorageWithSchema({
key: "config",
@@ -178,7 +179,11 @@ export function genericSet(
const set = genericSet(targetKey, targetValue, nosave);
if (!set) {
throw new Error(
- `Failed to set config key "${targetKey}" with value "${targetValue}" for ${metadata.displayString} config override.`
+ `Failed to set config key "${stringifyConfigValue(
+ targetKey
+ )}" with value "${stringifyConfigValue(targetValue)}" for ${
+ metadata.displayString
+ } config override.`
);
}
}
@@ -712,6 +717,13 @@ export function setKeymapStyle(
return genericSet("keymapStyle", style, nosave);
}
+export function setKeymapCustom(
+ keymapCustom: ConfigSchemas.KeymapCustom,
+ nosave?: boolean
+): boolean {
+ return genericSet("keymapCustom", keymapCustom, nosave);
+}
+
export function setKeymapLayout(
layout: ConfigSchemas.KeymapLayout,
nosave?: boolean
diff --git a/frontend/src/ts/constants/data-keys.ts b/frontend/src/ts/constants/data-keys.ts
new file mode 100644
index 000000000000..f620c4c81f20
--- /dev/null
+++ b/frontend/src/ts/constants/data-keys.ts
@@ -0,0 +1,48 @@
+export const dataKeys: { [key: string]: string } = {
+ a: "aA",
+ b: "bB",
+ c: "cC",
+ d: "dD",
+ e: "eE",
+ f: "fF",
+ g: "gG",
+ h: "hH",
+ i: "iI",
+ j: "jJ",
+ k: "kK",
+ l: "lL",
+ m: "mM",
+ n: "nN",
+ o: "oO",
+ p: "pP",
+ q: "qQ",
+ r: "rR",
+ s: "sS",
+ t: "tT",
+ u: "uU",
+ v: "vV",
+ w: "wW",
+ x: "xX",
+ y: "yY",
+ z: "zZ",
+ 1: "1!",
+ 2: "2@",
+ 3: "3#",
+ 4: "4$",
+ 5: "5%",
+ 6: "6^",
+ 7: "7&",
+ 8: "8*",
+ 9: "9(",
+ 0: "0)",
+ "-": "-_",
+ "=": "=+",
+ "[": "[{",
+ "]": "]}",
+ "\\": "\\|",
+ ";": ";:",
+ "'": "'"",
+ ",": ",<",
+ ".": ".>",
+ "/": "/?",
+};
diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts
index 61b2458f4034..8545ea188b5c 100644
--- a/frontend/src/ts/constants/default-config.ts
+++ b/frontend/src/ts/constants/default-config.ts
@@ -58,6 +58,7 @@ const obj = {
keymapLayout: "overrideSync",
keymapShowTopRow: "layout",
keymapSize: 1,
+ keymapCustom: [[]],
fontFamily: "Roboto_Mono",
smoothLineScroll: false,
alwaysShowDecimalPlaces: false,
diff --git a/frontend/src/ts/constants/layouts.ts b/frontend/src/ts/constants/layouts.ts
index ff8f5a9ec340..04f5f09b41ed 100644
--- a/frontend/src/ts/constants/layouts.ts
+++ b/frontend/src/ts/constants/layouts.ts
@@ -1,3 +1,3 @@
import { LayoutName, LayoutNameSchema } from "@monkeytype/schemas/layouts";
-export const LayoutsList:LayoutName[] = LayoutNameSchema._def.values;
\ No newline at end of file
+export const LayoutsList: LayoutName[] = LayoutNameSchema._def.values;
diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts
index 30b6c5b6cc56..71d01c8cfad4 100644
--- a/frontend/src/ts/controllers/challenge-controller.ts
+++ b/frontend/src/ts/controllers/challenge-controller.ts
@@ -20,6 +20,7 @@ import { CompletedEvent } from "@monkeytype/schemas/results";
import { areUnsortedArraysEqual } from "../utils/arrays";
import { tryCatch } from "@monkeytype/util/trycatch";
import { Challenge } from "@monkeytype/schemas/challenges";
+import { stringifyConfigValue } from "../utils/strings";
let challengeLoading = false;
@@ -133,7 +134,11 @@ function verifyRequirement(
const configValue = requirementValue[configKey];
if (Config[configKey as keyof ConfigType] !== configValue) {
requirementsMet = false;
- failReasons.push(`${configKey} not set to ${configValue}`);
+ failReasons.push(
+ `${stringifyConfigValue(configKey)} not set to ${stringifyConfigValue(
+ configValue
+ )}`
+ );
}
}
}
diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts
index da8a8dae4065..08a41ba28df3 100644
--- a/frontend/src/ts/elements/keymap.ts
+++ b/frontend/src/ts/elements/keymap.ts
@@ -14,6 +14,12 @@ import * as ShiftTracker from "../test/shift-tracker";
import * as AltTracker from "../test/alt-tracker";
import * as KeyConverter from "../utils/key-converter";
import { getActiveFunboxNames } from "../test/funbox/list";
+import { getCustomKeymapSyle } from "../utils/custom-keymap";
+import {
+ KeymapCustom,
+ KeymapLayout,
+ KeymapLegendStyle,
+} from "@monkeytype/schemas/configs";
import { areSortedArraysEqual } from "../utils/arrays";
import { LayoutObject } from "@monkeytype/schemas/layouts";
@@ -458,6 +464,22 @@ export async function refresh(): Promise {
});
}
+ if (Config.keymapStyle === "custom") {
+ const {
+ keymapCustom,
+ keymapLayout,
+ keymapLegendStyle,
+ }: {
+ keymapCustom: KeymapCustom;
+ keymapLayout: KeymapLayout;
+ keymapLegendStyle: KeymapLegendStyle;
+ } = Config;
+ keymapElement = getCustomKeymapSyle(
+ keymapCustom,
+ keymapLayout,
+ keymapLegendStyle
+ );
+ }
$("#keymap").html(keymapElement);
$("#keymap").removeClass("staggered");
@@ -467,6 +489,7 @@ export async function refresh(): Promise {
$("#keymap").removeClass("alice");
$("#keymap").removeClass("steno");
$("#keymap").removeClass("steno_matrix");
+ $("#keymap").removeClass("custom");
$("#keymap").addClass(Config.keymapStyle);
} catch (e) {
if (e instanceof Error) {
@@ -552,7 +575,11 @@ async function updateLegends(): Promise {
}
for (let i = 0; i < layoutKeys.length; i++) {
- const layoutKey = layoutKeys[i] as string[];
+ let layoutKey = layoutKeys[i] as string[];
+
+ if (Config.keymapStyle === "custom") {
+ layoutKey = layoutKey[0]?.split("") ?? ["", ""];
+ }
const key = keys[i];
const lowerCaseCharacter = layoutKey[0];
const upperCaseCharacter = layoutKey[1];
@@ -561,7 +588,9 @@ async function updateLegends(): Promise {
key === undefined ||
layoutKey === undefined ||
lowerCaseCharacter === undefined ||
- upperCaseCharacter === undefined
+ lowerCaseCharacter === "" ||
+ upperCaseCharacter === undefined ||
+ upperCaseCharacter === ""
)
continue;
diff --git a/frontend/src/ts/elements/settings/custom-keymap-picker.ts b/frontend/src/ts/elements/settings/custom-keymap-picker.ts
new file mode 100644
index 000000000000..6d6f01d2b554
--- /dev/null
+++ b/frontend/src/ts/elements/settings/custom-keymap-picker.ts
@@ -0,0 +1,84 @@
+import FileStorage from "../../utils/file-storage";
+import * as Notifications from "../notifications";
+import { keymapToString, stringToKeymap } from "../../utils/custom-keymap";
+import * as UpdateConfig from "../../config";
+import { KeymapCustom } from "@monkeytype/schemas/configs";
+
+const parentEl = document.querySelector(
+ ".pageSettings .section[data-config-name='keymapCustom']"
+);
+const usingLocalKeymapEl = parentEl?.querySelector(".usingLocalKeymap");
+const separatorEl = parentEl?.querySelector(".separator");
+const uploadContainerEl = parentEl?.querySelector(".uploadContainer");
+const inputAndButtonEl = parentEl?.querySelector(".textareaAndButton");
+
+async function readFileAsData(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = reject;
+ reader.readAsText(file);
+ });
+}
+
+async function applyCustomKeymap(keymap: KeymapCustom): Promise {
+ $(
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea"
+ ).val("");
+ const didConfigSave = UpdateConfig.setKeymapCustom(keymap);
+ if (didConfigSave) {
+ Notifications.add("Saved", 1, {
+ duration: 1,
+ });
+ }
+}
+
+export async function updateUI(): Promise {
+ if (await FileStorage.hasFile("LocalKeymapFile")) {
+ usingLocalKeymapEl?.classList.remove("hidden");
+ separatorEl?.classList.add("hidden");
+ uploadContainerEl?.classList.add("hidden");
+ inputAndButtonEl?.classList.add("hidden");
+ } else {
+ usingLocalKeymapEl?.classList.add("hidden");
+ separatorEl?.classList.remove("hidden");
+ uploadContainerEl?.classList.remove("hidden");
+ inputAndButtonEl?.classList.remove("hidden");
+ }
+}
+
+usingLocalKeymapEl
+ ?.querySelector("button")
+ ?.addEventListener("click", async () => {
+ await FileStorage.deleteFile("LocalKeymapFile");
+ await updateUI();
+ await applyCustomKeymap([]);
+ });
+
+uploadContainerEl
+ ?.querySelector("input[type='file']")
+ ?.addEventListener("change", async (e) => {
+ const fileInput = e.target as HTMLInputElement;
+ const file = fileInput.files?.[0];
+
+ if (!file) {
+ return;
+ }
+
+ // check type
+ if (!file.type.match(/application\/json/)) {
+ Notifications.add("Unsupported keymap format", 0);
+ fileInput.value = "";
+ return;
+ }
+
+ //sanitize input
+ const data = await readFileAsData(file);
+ const keymapData = stringToKeymap(data);
+ await FileStorage.storeFile("LocalKeymapFile", keymapToString(keymapData));
+
+ await updateUI();
+ await applyCustomKeymap(keymapData);
+
+ fileInput.value = "";
+ });
diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts
index 63e5fa7b5c09..f360d9c14355 100644
--- a/frontend/src/ts/pages/settings.ts
+++ b/frontend/src/ts/pages/settings.ts
@@ -24,6 +24,7 @@ import {
FunboxName,
ConfigKeySchema,
ConfigKey,
+ KeymapCustom,
} from "@monkeytype/schemas/configs";
import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox";
import { getActiveFunboxNames } from "../test/funbox/list";
@@ -40,8 +41,10 @@ import { z } from "zod";
import { handleConfigInput } from "../elements/input-validation";
import { Fonts } from "../constants/fonts";
import * as CustomBackgroundPicker from "../elements/settings/custom-background-picker";
+import * as CustomKeymapPicker from "../elements/settings/custom-keymap-picker";
import * as CustomFontPicker from "../elements/settings/custom-font-picker";
import * as AuthEvent from "../observables/auth-event";
+import { keymapToString, stringToKeymap } from "../utils/custom-keymap";
let settingsInitialized = false;
@@ -117,6 +120,9 @@ async function initGroups(): Promise {
$(".pageSettings .section[data-config-name='keymapSize']").addClass(
"hidden"
);
+ $(".pageSettings .section[data-config-name='keymapCustom']").addClass(
+ "hidden"
+ );
} else {
$(
".pageSettings .section[data-config-name='keymapStyle']"
@@ -133,6 +139,18 @@ async function initGroups(): Promise {
$(
".pageSettings .section[data-config-name='keymapSize']"
).removeClass("hidden");
+ $(
+ ".pageSettings .section[data-config-name='keymapSize']"
+ ).removeClass("hidden");
+ if (Config.keymapStyle === "custom") {
+ $(
+ ".pageSettings .section[data-config-name='keymapCustom']"
+ ).removeClass("hidden");
+ } else {
+ $(
+ ".pageSettings .section[data-config-name='keymapCustom']"
+ ).addClass("hidden");
+ }
}
},
}
@@ -142,6 +160,11 @@ async function initGroups(): Promise {
UpdateConfig.setKeymapStyle,
"button"
);
+ groups["keymapCustom"] = new SettingsGroup(
+ "keymapCustom",
+ UpdateConfig.setKeymapCustom,
+ "button"
+ );
groups["keymapLayout"] = new SettingsGroup(
"keymapLayout",
UpdateConfig.setKeymapLayout,
@@ -857,6 +880,7 @@ export async function update(
await CustomBackgroundPicker.updateUI();
await updateFilterSectionVisibility();
await CustomFontPicker.updateUI();
+ await CustomKeymapPicker.updateUI();
const setInputValue = (
key: ConfigKey,
@@ -918,6 +942,12 @@ export async function update(
Config.fontSize
);
+ setInputValue(
+ "keymapCustom",
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea",
+ keymapToString(Config.keymapCustom)
+ );
+
setInputValue(
"maxLineWidth",
".pageSettings .section[data-config-name='maxLineWidth'] input",
@@ -1118,6 +1148,38 @@ $(".pageSettings .quickNav .links a").on("click", (e) => {
}
});
+$(
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton button.save"
+).on("click", () => {
+ const stringValue = $(
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea"
+ ).val() as string;
+ const keymap: KeymapCustom = stringToKeymap(stringValue);
+ const didConfigSave = UpdateConfig.setKeymapCustom(keymap);
+ if (didConfigSave) {
+ Notifications.add("Saved", 1, {
+ duration: 1,
+ });
+ }
+});
+
+$(
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea"
+).on("keypress", (e) => {
+ if (e.key === "Enter") {
+ const stringValue = $(
+ ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea"
+ ).val() as string;
+ const keymap: KeymapCustom = stringToKeymap(stringValue);
+ const didConfigSave = UpdateConfig.setKeymapCustom(keymap);
+ if (didConfigSave) {
+ Notifications.add("Saved", 1, {
+ duration: 1,
+ });
+ }
+ }
+});
+
let configEventDisabled = false;
export function setEventDisabled(value: boolean): void {
configEventDisabled = value;
@@ -1209,11 +1271,15 @@ ConfigEvent.subscribe((eventKey, eventValue) => {
if (eventKey === "fullConfigChangeFinished") setEventDisabled(false);
if (eventKey === "themeLight") {
$(
- `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light option[value="${eventValue}"]`
+ `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light option[value="${Strings.stringifyConfigValue(
+ eventValue
+ )}"]`
).attr("selected", "true");
} else if (eventKey === "themeDark") {
$(
- `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark option[value="${eventValue}"]`
+ `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark option[value="${Strings.stringifyConfigValue(
+ eventValue
+ )}"]`
).attr("selected", "true");
}
//make sure the page doesnt update a billion times when applying a preset/config at once
diff --git a/frontend/src/ts/test/funbox/funbox-validation.ts b/frontend/src/ts/test/funbox/funbox-validation.ts
index c01624f854bc..600e0f1241b1 100644
--- a/frontend/src/ts/test/funbox/funbox-validation.ts
+++ b/frontend/src/ts/test/funbox/funbox-validation.ts
@@ -142,7 +142,9 @@ export function canSetConfigWithCurrentFunboxes(
Notifications.add(
`You can't set ${Strings.camelCaseToWords(
key
- )} to ${value.toString()} with currently active funboxes.`,
+ )} to ${Strings.stringifyConfigValue(
+ value
+ )} with currently active funboxes.`,
0,
{
duration: 5,
@@ -184,7 +186,7 @@ export function canSetFunboxWithConfig(
errorStrings.push(
`${Strings.capitalizeFirstLetter(
Strings.camelCaseToWords(error.key)
- )} cannot be set to ${error.value.toString()}.`
+ )} cannot be set to ${Strings.stringifyConfigValue(error.value)}.`
);
}
Notifications.add(
diff --git a/frontend/src/ts/utils/custom-keymap.ts b/frontend/src/ts/utils/custom-keymap.ts
new file mode 100644
index 000000000000..6512ef6e69c9
--- /dev/null
+++ b/frontend/src/ts/utils/custom-keymap.ts
@@ -0,0 +1,370 @@
+import {
+ KeyProperties,
+ KeymapCustom,
+ KeymapCustomSchema,
+ KeymapLegendStyle,
+ KeymapLayout as Layout,
+} from "@monkeytype/schemas/configs";
+import { dataKeys as keyToDataObject } from "../constants/data-keys";
+import { sanitizeString } from "@monkeytype/util/strings";
+import { parseWithSchema } from "@monkeytype/util/json";
+import { stringifyConfigValue } from "./strings";
+
+const columnMultiplier = 8;
+const rowMultiplier = 8;
+const margin = 0.125;
+let basicSpan = 2;
+
+function keyToData(key: string): string {
+ return (key && keyToDataObject[key]) ?? "";
+}
+
+function isKeyProperties(
+ element: KeyProperties | string
+): element is KeyProperties {
+ return typeof element === "object" && element !== null;
+}
+
+function isOnlyInvisibleKey(
+ element: KeyProperties | string
+): element is KeyProperties {
+ return (
+ typeof element === "object" &&
+ element !== null &&
+ element.x !== undefined &&
+ element.w === undefined &&
+ element.h === undefined &&
+ element.y === undefined &&
+ element.rx === undefined &&
+ element.ry === undefined &&
+ element.r === undefined
+ );
+}
+
+function roundUpKeys(element: KeyProperties): KeyProperties {
+ for (let key in element) {
+ const k = key as keyof KeyProperties;
+ element[k] =
+ element[k] !== undefined
+ ? Math.round(element[k] * columnMultiplier) / columnMultiplier
+ : undefined;
+ }
+ return element;
+}
+
+function sanitizeKeymap(keymap: KeymapCustom): KeymapCustom {
+ return keymap.map((row: (KeyProperties | string)[]) => {
+ return row
+ .map((element: KeyProperties | string) => {
+ if (typeof element === "string") {
+ return sanitizeString(element);
+ }
+ if (
+ typeof element === "object" &&
+ element !== null &&
+ Object.keys(element).length > 0
+ ) {
+ return roundUpKeys(element);
+ } else {
+ return null;
+ }
+ })
+ .filter((el): el is KeyProperties | string => el !== null);
+ });
+}
+
+export function stringToKeymap(keymap: string): KeymapCustom {
+ try {
+ const isMatrix = /^\s*\[\s*\[[\s\S]*?\]\s*(,\s*\[[\s\S]*?\]\s*)*\]\s*$/;
+ const quoteKeymap = keymap.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":');
+ const processedKeymap = isMatrix.test(keymap)
+ ? quoteKeymap
+ : `[${quoteKeymap}]`;
+ const jsonKeymap: KeymapCustom = parseWithSchema(
+ processedKeymap,
+ KeymapCustomSchema
+ );
+ const test = sanitizeKeymap(jsonKeymap);
+ return test;
+ } catch (error) {
+ throw new Error("Wrong keymap, make sure you copy the right JSON file!");
+ }
+}
+
+export function keymapToString(keymap: KeymapCustom): string {
+ try {
+ if (
+ (keymap?.length === 1 && keymap[0]?.length === 0) ||
+ keymap?.length === 0
+ ) {
+ return "";
+ }
+ let jsonString = JSON.stringify(keymap ?? "");
+
+ jsonString = jsonString.replace(/"(\w+)":/g, "$1:");
+
+ return jsonString;
+ } catch (error) {
+ console.error("Error converting keymap to string:", error);
+ return "";
+ }
+}
+
+function createHtmlKey(
+ keyString: string,
+ legendStyle: string,
+ column: number,
+ size: number,
+ row: number,
+ rowSpan: number,
+ isInvisible: boolean,
+ rotationAngle: number | undefined,
+ columnOffset: number,
+ rowOffset: number
+): string {
+ const dataKey = !isInvisible ? keyToData(keyString) : "";
+ const fontSize = keyString.length > 2 ? "font-size: 0.6rem; " : "";
+ const span = !isInvisible
+ ? `${keyString}`
+ : "";
+ const remUnit = (basicSpan + 2 * margin) / columnMultiplier;
+ let transform =
+ rotationAngle !== undefined
+ ? `transform-origin: ${(columnOffset ?? 0) * remUnit}rem ${
+ (rowOffset ?? 0) * remUnit
+ }rem; transform: rotate(${rotationAngle}deg);`
+ : "";
+ return ``.replace(/(\r\n|\r|\n|\s{2,})/g, "");
+}
+
+function createSpaceKey(
+ layout: Layout,
+ legendStyle: string,
+ column: number,
+ size: number,
+ row: number,
+ rowSpan: number,
+ rotationAngle: number | undefined,
+ columnOffset: number,
+ rowOffset: number
+): string {
+ const remUnit = (basicSpan + 2 * margin) / columnMultiplier;
+ let transform =
+ rotationAngle !== undefined
+ ? `transform-origin: ${(columnOffset ?? 0) * remUnit}rem ${
+ (rowOffset ?? 0) * remUnit
+ }rem; transform: rotate(${rotationAngle}deg);`
+ : "";
+ return `
+
${sanitizeString(layout)}
+
`.replace(/(\r\n|\r|\n|\s{2,})/g, "");
+}
+
+function createKey(
+ keyString: string,
+ layout: Layout,
+ legendStyle: string,
+ column: number,
+ size: number,
+ row: number,
+ rowSpan: number,
+ isInvisible: boolean,
+ rotationAngle: number | undefined,
+ columnOffset: number,
+ rowOffset: number
+): string {
+ if (keyString === "spc") {
+ return createSpaceKey(
+ layout,
+ legendStyle,
+ column,
+ size,
+ row,
+ rowSpan,
+ rotationAngle,
+ columnOffset,
+ rowOffset
+ );
+ } else {
+ return createHtmlKey(
+ keyString,
+ legendStyle,
+ column,
+ size,
+ row,
+ rowSpan,
+ isInvisible,
+ rotationAngle,
+ columnOffset,
+ rowOffset
+ );
+ }
+}
+
+function calculateRowSpan(): void {
+ const flag: Element | null = document.querySelector(
+ "#mobileTestConfigButton"
+ );
+ if (flag !== null) {
+ const flagValue = getComputedStyle(flag).display;
+ const grid: HTMLElement | null = document.querySelector(".keyboard-grid");
+ // checking wheter or not the Test Setting is visible and use this as a change point
+ basicSpan = flagValue !== "none" ? 1.25 : 2;
+
+ const newSpanSize = (basicSpan + 2 * margin) / columnMultiplier;
+
+ if (grid !== null && grid !== undefined) {
+ grid.style.setProperty("--colSpan", `${newSpanSize}rem`);
+ grid.style.setProperty("--rowSpan", `${newSpanSize}rem`);
+ }
+ }
+}
+
+export function getCustomKeymapSyle(
+ keymapStyle: KeymapCustom,
+ layout: Layout,
+ keymapLegendStyle: KeymapLegendStyle
+): string {
+ calculateRowSpan();
+ const keymapCopy = [...keymapStyle];
+ let legendStyle = "";
+ if (keymapLegendStyle === "uppercase") {
+ legendStyle = "text-transform: capitalize;";
+ } else if (keymapLegendStyle === "blank") {
+ legendStyle = "display: none; transition: none;";
+ }
+ let maxColumn = 1,
+ maxRow = 1,
+ rotationRow = 1,
+ currentRow = 1,
+ rowOffset = 0,
+ rotationAngle: number | undefined,
+ isRotationSectionStarted: boolean,
+ rotationColumn: number | undefined;
+ const keymapHtml = keymapCopy.map((row: (KeyProperties | string)[]) => {
+ const rowCopy = [...row];
+ let currentColumn = rotationColumn !== undefined ? rotationColumn : 1,
+ columnOffset = 0;
+ const rowHtml = rowCopy.map(
+ (element: KeyProperties | string, index: number) => {
+ let keyHtml: string = "",
+ keyString: string =
+ typeof element === "string"
+ ? sanitizeString(element).toLowerCase()
+ : "",
+ columnSpan = 1,
+ rowSpan = 1,
+ isInvisible = false;
+ if (isOnlyInvisibleKey(element) && element.x !== undefined) {
+ maxColumn = currentColumn > maxColumn ? currentColumn : maxColumn;
+ currentColumn += element.x * columnMultiplier;
+ if (isRotationSectionStarted) {
+ columnOffset += -1 * element.x * columnMultiplier;
+ }
+ return;
+ } else if (isKeyProperties(element)) {
+ if (element.w !== undefined && "w" in element) {
+ columnSpan = element.w;
+ }
+ if (element.y !== undefined && "y" in element) {
+ currentRow += element.y * rowMultiplier;
+ if (isRotationSectionStarted) {
+ rowOffset += -1 * element.y * rowMultiplier;
+ }
+ }
+ if (element.x !== undefined && "x" in element) {
+ currentColumn += element.x * columnMultiplier;
+ if (isRotationSectionStarted) {
+ columnOffset += -1 * element.x * columnMultiplier;
+ }
+ }
+ if (element.h !== undefined && "h" in element) {
+ rowSpan = element.h;
+ }
+ if (element.r !== undefined && "r" in element) {
+ rotationAngle = element.r;
+ columnOffset = -(currentColumn - 1);
+ rowOffset = -(currentRow - 1);
+ if (element.rx !== undefined || element.ry !== undefined) {
+ currentColumn =
+ element.rx !== undefined
+ ? element.rx * columnMultiplier + 1
+ : rotationColumn ?? 1;
+ currentRow =
+ element.ry !== undefined
+ ? element.ry * rowMultiplier + 1
+ : rotationRow ?? 1;
+ rotationColumn = currentColumn;
+ rotationRow = currentRow;
+ rowOffset = 0;
+ columnOffset = 0;
+ if (element.y !== undefined && "y" in element) {
+ currentRow += element.y * rowMultiplier;
+ rowOffset = -1 * element.y * rowMultiplier;
+ }
+ if (element.x !== undefined && "x" in element) {
+ currentColumn += element.x * columnMultiplier;
+ columnOffset = -1 * element.x * columnMultiplier;
+ }
+ }
+ isRotationSectionStarted = true;
+ }
+ keyString = sanitizeString(
+ stringifyConfigValue(rowCopy[index + 1]) ?? ""
+ ).toLowerCase();
+ rowCopy.splice(index, 1);
+ }
+
+ keyHtml += createKey(
+ keyString,
+ layout,
+ legendStyle,
+ currentColumn,
+ columnSpan,
+ currentRow,
+ rowSpan,
+ isInvisible,
+ rotationAngle,
+ columnOffset,
+ rowOffset
+ );
+ maxColumn = currentColumn > maxColumn ? currentColumn : maxColumn;
+ currentColumn += columnSpan * columnMultiplier;
+ if (isRotationSectionStarted) {
+ columnOffset += -1 * columnSpan * columnMultiplier;
+ }
+ return keyHtml;
+ }
+ );
+ maxRow = currentRow > maxRow ? currentRow : maxRow;
+ currentRow += rowMultiplier;
+ if (isRotationSectionStarted) {
+ rowOffset += -1 * rowMultiplier;
+ }
+ return rowHtml.join("");
+ });
+ const cols = maxColumn + columnMultiplier - 1,
+ rows = maxRow + rowMultiplier - 1,
+ colSpan = (basicSpan + 2 * margin) / columnMultiplier,
+ rowSpan = (basicSpan + 2 * margin) / rowMultiplier;
+
+ return `${keymapHtml.join("")}
`;
+}
+
+window.addEventListener("resize", calculateRowSpan);
diff --git a/frontend/src/ts/utils/file-storage.ts b/frontend/src/ts/utils/file-storage.ts
index bb8915109e57..55fba8673f19 100644
--- a/frontend/src/ts/utils/file-storage.ts
+++ b/frontend/src/ts/utils/file-storage.ts
@@ -7,7 +7,10 @@ type FileDB = DBSchema & {
};
};
-type Filename = "LocalBackgroundFile" | "LocalFontFamilyFile";
+type Filename =
+ | "LocalBackgroundFile"
+ | "LocalFontFamilyFile"
+ | "LocalKeymapFile";
class FileStorage {
private dbPromise: Promise>;
diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts
index 0786bd3e41d0..d47395f309dc 100644
--- a/frontend/src/ts/utils/strings.ts
+++ b/frontend/src/ts/utils/strings.ts
@@ -265,3 +265,16 @@ export function isWordRightToLeft(
export const __testing = {
hasRTLCharacters,
};
+
+/**
+ * Stringify properly JSON objects to avoid no-base-to-string
+ * eslint errors
+ */
+export function stringifyConfigValue(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ if (typeof value !== "object") {
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ return value.toString();
+ }
+ return JSON.stringify(value);
+}
diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts
index c5f6ebbe7452..32b11480806b 100644
--- a/packages/schemas/src/configs.ts
+++ b/packages/schemas/src/configs.ts
@@ -91,9 +91,31 @@ export const KeymapStyleSchema = z.enum([
"split_matrix",
"steno",
"steno_matrix",
+ "custom",
]);
export type KeymapStyle = z.infer;
+export const KeyPropertiesSchema = z.object({
+ w: z.number().optional(),
+ h: z.number().optional(),
+ x: z.number().optional(),
+ y: z.number().optional(),
+ r: z.number().optional(),
+ rx: z.number().optional(),
+ ry: z.number().optional(),
+});
+
+export type KeyProperties = z.infer;
+
+export const KeymapCustomSchema = z.array(
+ z
+ .array(z.union([z.string(), KeyPropertiesSchema.partial()]))
+ .refine((val) => JSON.stringify(val).length <= 4096, {
+ message: "Keymap data must be less than 4096 chars.",
+ })
+);
+export type KeymapCustom = z.infer;
+
export const KeymapLegendStyleSchema = z.enum([
"lowercase",
"uppercase",
@@ -435,6 +457,7 @@ export const ConfigSchema = z
keymapMode: KeymapModeSchema,
keymapLayout: KeymapLayoutSchema,
keymapStyle: KeymapStyleSchema,
+ keymapCustom: KeymapCustomSchema,
keymapLegendStyle: KeymapLegendStyleSchema,
keymapShowTopRow: KeymapShowTopRowSchema,
keymapSize: KeymapSizeSchema,
@@ -570,6 +593,7 @@ export const ConfigGroupsLiteral = {
keymapMode: "appearance",
keymapLayout: "appearance",
keymapStyle: "appearance",
+ keymapCustom: "appearance",
keymapLegendStyle: "appearance",
keymapShowTopRow: "appearance",
keymapSize: "appearance",
diff --git a/packages/util/src/strings.ts b/packages/util/src/strings.ts
new file mode 100644
index 000000000000..dce3d0020680
--- /dev/null
+++ b/packages/util/src/strings.ts
@@ -0,0 +1,15 @@
+export function sanitizeString(str: string): string {
+ if (str === "") {
+ return "";
+ }
+
+ return str
+ .replace(/`/g, "`")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+ .replace(/\\/g, "\")
+ .replace(/\n/, "")
+ .replace(/script/g, "");
+}